feat: add account editing functionality with UI modal and backend handler

This commit is contained in:
CJACK.
2026-04-22 17:20:44 +00:00
parent f14969eca5
commit 77484bf813
11 changed files with 293 additions and 11 deletions

View File

@@ -140,6 +140,7 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
| POST | `/admin/proxies/test` | Admin | Test proxy connectivity |
| GET | `/admin/accounts` | Admin | Paginated account list |
| POST | `/admin/accounts` | Admin | Add account |
| PUT | `/admin/accounts/{identifier}` | Admin | Update account name/remark |
| DELETE | `/admin/accounts/{identifier}` | Admin | Delete account |
| PUT | `/admin/accounts/{identifier}/proxy` | Admin | Bind/unbind proxy for an account |
| GET | `/admin/queue/status` | Admin | Account queue status |
@@ -843,6 +844,16 @@ Returned items also include `test_status`, usually `ok` or `failed`.
**Response**: `{"success": true, "total_accounts": 6}`
### `PUT /admin/accounts/{identifier}`
Updates the `name` / `remark` of the specified account. The path `identifier` can be email or mobile and cannot be changed.
```json
{"name": "Primary account", "remark": "Shared with the team"}
```
**Response**: `{"success": true, "total_accounts": 6}`
### `DELETE /admin/accounts/{identifier}`
`identifier` can be email, mobile, or the synthetic id for token-only accounts (`token:<hash>`).

11
API.md
View File

@@ -140,6 +140,7 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
| POST | `/admin/proxies/test` | Admin | 测试代理连通性 |
| GET | `/admin/accounts` | Admin | 分页账号列表 |
| POST | `/admin/accounts` | Admin | 添加账号 |
| PUT | `/admin/accounts/{identifier}` | Admin | 更新账号 name/remark |
| DELETE | `/admin/accounts/{identifier}` | Admin | 删除账号 |
| PUT | `/admin/accounts/{identifier}/proxy` | Admin | 为账号绑定/解绑代理 |
| GET | `/admin/queue/status` | Admin | 账号队列状态 |
@@ -842,6 +843,16 @@ data: {"type":"message_stop"}
**响应**`{"success": true, "total_accounts": 6}`
### `PUT /admin/accounts/{identifier}`
更新指定账号的 `name` / `remark`。路径参数中的 `identifier` 可以是 email 或 mobile且不可修改。
```json
{"name": "主账号", "remark": "团队共享"}
```
**响应**`{"success": true, "total_accounts": 6}`
### `DELETE /admin/accounts/{identifier}`
`identifier` 可为 email、mobile或 token-only 账号的合成标识(`token:<hash>`)。

View File

@@ -37,6 +37,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Post("/proxies/test", h.testProxy)
pr.Get("/accounts", h.listAccounts)
pr.Post("/accounts", h.addAccount)
pr.Put("/accounts/{identifier}", h.updateAccount)
pr.Delete("/accounts/{identifier}", h.deleteAccount)
pr.Put("/accounts/{identifier}/proxy", h.updateAccountProxy)
pr.Get("/queue/status", h.queueStatus)

View File

@@ -116,6 +116,46 @@ func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {
identifier = decoded
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
return
}
name, nameOK := fieldStringOptional(req, "name")
remark, remarkOK := fieldStringOptional(req, "remark")
err := h.Store.Update(func(c *config.Config) error {
for i, acc := range c.Accounts {
if !accountMatchesIdentifier(acc, identifier) {
continue
}
if nameOK {
c.Accounts[i].Name = name
}
if remarkOK {
c.Accounts[i].Remark = remark
}
return nil
}
return newRequestError("账号不存在")
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {

View File

@@ -7,6 +7,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestListAccountsPageSizeCapIs5000(t *testing.T) {
@@ -51,3 +53,36 @@ func TestListAccountsPageSizeAbove5000ClampedTo5000(t *testing.T) {
t.Fatalf("expected page_size clamped to 5000, got %v", payload["page_size"])
}
}
func TestUpdateAccountMetadataPreservesCredentials(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","name":"old name","remark":"old remark","password":"secret"}]
}`)
r := chi.NewRouter()
r.Put("/admin/accounts/{identifier}", h.updateAccount)
body := []byte(`{"name":"new name","remark":"new remark"}`)
req := httptest.NewRequest(http.MethodPut, "/admin/accounts/u@example.com", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Accounts) != 1 {
t.Fatalf("unexpected accounts after update: %#v", snap.Accounts)
}
acc := snap.Accounts[0]
if acc.Email != "u@example.com" {
t.Fatalf("identifier changed unexpectedly: %#v", acc)
}
if acc.Name != "new name" || acc.Remark != "new remark" {
t.Fatalf("metadata update did not persist: %#v", acc)
}
if acc.Password != "secret" {
t.Fatalf("password should be preserved, got %#v", acc)
}
}

View File

@@ -6,6 +6,7 @@ import ApiKeysPanel from './ApiKeysPanel'
import AccountsTable from './AccountsTable'
import AddKeyModal from './AddKeyModal'
import AddAccountModal from './AddAccountModal'
import EditAccountModal from './EditAccountModal'
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
const { t } = useI18n()
@@ -35,7 +36,14 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
closeKeyModal,
editingKey,
showAddAccount,
setShowAddAccount,
openAddAccount,
closeAddAccount,
showEditAccount,
editingAccount,
editAccount,
setEditAccount,
openEditAccount,
closeEditAccount,
newKey,
setNewKey,
copiedKey,
@@ -52,6 +60,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
addKey,
deleteKey,
addAccount,
updateAccount,
deleteAccount,
testAccount,
testAllAccounts,
@@ -121,7 +130,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
resolveAccountIdentifier={resolveAccountIdentifier}
proxies={config?.proxies || []}
onTestAll={testAllAccounts}
onShowAddAccount={() => setShowAddAccount(true)}
onShowAddAccount={openAddAccount}
onEditAccount={openEditAccount}
onTestAccount={testAccount}
onDeleteAccount={deleteAccount}
onDeleteAllSessions={deleteAllSessions}
@@ -151,9 +161,20 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
newAccount={newAccount}
setNewAccount={setNewAccount}
loading={loading}
onClose={() => setShowAddAccount(false)}
onClose={closeAddAccount}
onAdd={addAccount}
/>
<EditAccountModal
show={showEditAccount}
t={t}
editingAccount={editingAccount}
editAccount={editAccount}
setEditAccount={setEditAccount}
loading={loading}
onClose={closeEditAccount}
onSave={updateAccount}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react'
import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX } from 'lucide-react'
import clsx from 'clsx'
export default function AccountsTable({
@@ -20,6 +20,7 @@ export default function AccountsTable({
proxies,
onTestAll,
onShowAddAccount,
onEditAccount,
onTestAccount,
onDeleteAccount,
onDeleteAllSessions,
@@ -180,6 +181,14 @@ export default function AccountsTable({
</option>
))}
</select>
<button
onClick={() => onEditAccount(acc)}
disabled={!id}
className="p-1 lg:p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={id ? t('accountManager.editAccountTitle') : t('accountManager.invalidIdentifier')}
>
<Pencil className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
</button>
<button
onClick={() => onTestAccount(id)}
disabled={testing[id]}

View File

@@ -0,0 +1,65 @@
import { X } from 'lucide-react'
export default function EditAccountModal({
show,
t,
editingAccount,
editAccount,
setEditAccount,
loading,
onClose,
onSave,
}) {
if (!show || !editingAccount) {
return null
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
<div className="p-4 border-b border-border flex justify-between items-start gap-4">
<div className="min-w-0">
<h3 className="font-semibold">{t('accountManager.modalEditAccountTitle')}</h3>
<p className="mt-1 text-xs text-muted-foreground">{t('accountManager.editAccountHint')}</p>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="rounded-lg border border-border bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-muted-foreground mb-1">{t('accountManager.accountIdentifierLabel')}</div>
<code className="text-sm font-mono text-foreground break-all">{editingAccount.identifier}</code>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.namePlaceholder')}
value={editAccount.name}
onChange={e => setEditAccount({ ...editAccount, name: e.target.value })}
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.remarkOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.remarkPlaceholder')}
value={editAccount.remark}
onChange={e => setEditAccount({ ...editAccount, remark: e.target.value })}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={onSave} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.editAccountLoading') : t('accountManager.editAccountAction')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -4,9 +4,12 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const [showAddKey, setShowAddKey] = useState(false)
const [editingKey, setEditingKey] = useState(null)
const [showAddAccount, setShowAddAccount] = useState(false)
const [showEditAccount, setShowEditAccount] = useState(false)
const [editingAccount, setEditingAccount] = useState(null)
const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
const [copiedKey, setCopiedKey] = useState(null)
const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '' })
const [editAccount, setEditAccount] = useState({ name: '', remark: '' })
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false)
@@ -38,6 +41,42 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
setNewKey({ key: '', name: '', remark: '' })
}
const openAddAccount = () => {
setShowEditAccount(false)
setEditingAccount(null)
setEditAccount({ name: '', remark: '' })
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
setShowAddAccount(true)
}
const closeAddAccount = () => {
setShowAddAccount(false)
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
}
const openEditAccount = (account) => {
const identifier = resolveAccountIdentifier(account)
if (!identifier) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
setShowAddAccount(false)
setEditingAccount({
identifier,
})
setEditAccount({
name: account?.name || '',
remark: account?.remark || '',
})
setShowEditAccount(true)
}
const closeEditAccount = () => {
setShowEditAccount(false)
setEditingAccount(null)
setEditAccount({ name: '', remark: '' })
}
const addKey = async () => {
const isEditing = Boolean(editingKey?.key)
if (!isEditing && !newKey.key.trim()) {
@@ -104,8 +143,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
})
if (res.ok) {
onMessage('success', t('accountManager.addAccountSuccess'))
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
setShowAddAccount(false)
closeAddAccount()
fetchAccounts(1)
onRefresh()
} else {
@@ -119,6 +157,35 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
}
}
const updateAccount = async () => {
const identifier = String(editingAccount?.identifier || '').trim()
if (!identifier) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
setLoading(true)
try {
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(identifier)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editAccount),
})
if (res.ok) {
onMessage('success', t('accountManager.updateAccountSuccess'))
closeEditAccount()
fetchAccounts()
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.requestFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteAccount = async (id) => {
const identifier = String(id || '').trim()
if (!identifier) {
@@ -285,7 +352,14 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
closeKeyModal,
editingKey,
showAddAccount,
setShowAddAccount,
openAddAccount,
closeAddAccount,
showEditAccount,
editingAccount,
editAccount,
setEditAccount,
openEditAccount,
closeEditAccount,
newKey,
setNewKey,
copiedKey,
@@ -302,6 +376,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
addKey,
deleteKey,
addAccount,
updateAccount,
deleteAccount,
testAccount,
testAllAccounts,

View File

@@ -98,6 +98,7 @@
"addKeySuccess": "API key added successfully.",
"updateKeySuccess": "API key updated successfully.",
"addAccountSuccess": "Account added successfully.",
"updateAccountSuccess": "Account metadata updated successfully.",
"requiredFields": "Password and email/mobile are required.",
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
"deleteAccountConfirm": "Are you sure you want to delete this account?",
@@ -111,16 +112,17 @@
"accountsUnit": "accounts",
"threadsUnit": "threads",
"apiKeysTitle": "API Keys",
"apiKeysDesc": "Manage the API access key pool",
"apiKeysDesc": "Manage the API access key pool. Click the pencil icon on each row to edit name and remark.",
"addKey": "Add key",
"editKeyTitle": "Edit key",
"editAccountTitle": "Edit account",
"copied": "Copied",
"copyFailed": "Copy failed",
"copyKeyTitle": "Copy key",
"deleteKeyTitle": "Delete key",
"noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool",
"accountsDesc": "Manage the DeepSeek account pool and edit name/remark.",
"testAll": "Refresh all tokens",
"addAccount": "Add account",
"testingAllAccounts": "Refreshing tokens for all accounts...",
@@ -131,6 +133,7 @@
"noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key",
"modalEditKeyTitle": "Edit API key",
"modalEditAccountTitle": "Edit account details",
"newKeyLabel": "New key value",
"newKeyPlaceholder": "Enter a custom API key",
"keyLabel": "Key value",
@@ -142,6 +145,10 @@
"addKeyAction": "Add key",
"editKeyLoading": "Saving...",
"editKeyAction": "Save changes",
"editAccountHint": "Only name and remark can be changed here. The account identifier stays the same.",
"accountIdentifierLabel": "Account identifier",
"editAccountLoading": "Saving...",
"editAccountAction": "Save changes",
"modalAddAccountTitle": "Add DeepSeek account",
"nameOptional": "Name (optional)",
"namePlaceholder": "e.g. Primary Account A",

View File

@@ -98,6 +98,7 @@
"addKeySuccess": "API 密钥添加成功",
"updateKeySuccess": "API 密钥更新成功",
"addAccountSuccess": "账号添加成功",
"updateAccountSuccess": "账号信息更新成功",
"requiredFields": "需要填写密码以及邮箱或手机号",
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?",
@@ -111,16 +112,17 @@
"accountsUnit": "个账号",
"threadsUnit": "线程",
"apiKeysTitle": "API 密钥",
"apiKeysDesc": "管理 API 访问密钥池",
"apiKeysDesc": "管理 API 访问密钥池,点每行右侧铅笔可修改名称和备注",
"addKey": "添加密钥",
"editKeyTitle": "编辑密钥",
"editAccountTitle": "编辑账号",
"copied": "已复制",
"copyFailed": "复制失败",
"copyKeyTitle": "复制密钥",
"deleteKeyTitle": "删除密钥",
"noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池",
"accountsDesc": "管理 DeepSeek 账号池,支持修改名称和备注",
"testAll": "刷新全部 Token",
"addAccount": "添加账号",
"testingAllAccounts": "正在刷新所有账号 Token...",
@@ -131,6 +133,7 @@
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
"modalEditKeyTitle": "编辑 API 密钥",
"modalEditAccountTitle": "编辑账号信息",
"newKeyLabel": "新密钥值",
"newKeyPlaceholder": "输入自定义 API 密钥",
"keyLabel": "密钥值",
@@ -142,6 +145,10 @@
"addKeyAction": "添加密钥",
"editKeyLoading": "保存中...",
"editKeyAction": "保存修改",
"editAccountHint": "这里只能修改名称和备注,账号标识保持不变。",
"accountIdentifierLabel": "账号标识",
"editAccountLoading": "保存中...",
"editAccountAction": "保存修改",
"modalAddAccountTitle": "添加 DeepSeek 账号",
"nameOptional": "名称(可选)",
"namePlaceholder": "例如:主账号 A",