diff --git a/API.en.md b/API.en.md index 1629d9b..11e597c 100644 --- a/API.en.md +++ b/API.en.md @@ -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:`). diff --git a/API.md b/API.md index 8aea84d..0793d51 100644 --- a/API.md +++ b/API.md @@ -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:`)。 diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 3f84a18..a3eb796 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -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) diff --git a/internal/admin/handler_accounts_crud.go b/internal/admin/handler_accounts_crud.go index 9b888e7..b9d7146 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/admin/handler_accounts_crud.go @@ -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 { diff --git a/internal/admin/handler_accounts_crud_test.go b/internal/admin/handler_accounts_crud_test.go index 2b286a3..fb4d3cc 100644 --- a/internal/admin/handler_accounts_crud_test.go +++ b/internal/admin/handler_accounts_crud_test.go @@ -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) + } +} diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index 9b88ca1..f1ab409 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -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} /> + + ) } diff --git a/webui/src/features/account/AccountsTable.jsx b/webui/src/features/account/AccountsTable.jsx index f01e47f..14915ed 100644 --- a/webui/src/features/account/AccountsTable.jsx +++ b/webui/src/features/account/AccountsTable.jsx @@ -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({ ))} + + +
+
+
{t('accountManager.accountIdentifierLabel')}
+ {editingAccount.identifier} +
+
+ + setEditAccount({ ...editAccount, name: e.target.value })} + autoFocus + /> +
+
+ + setEditAccount({ ...editAccount, remark: e.target.value })} + /> +
+
+ + +
+
+ + + ) +} diff --git a/webui/src/features/account/useAccountActions.js b/webui/src/features/account/useAccountActions.js index 4c4a612..f859eeb 100644 --- a/webui/src/features/account/useAccountActions.js +++ b/webui/src/features/account/useAccountActions.js @@ -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, diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 8bfaaf1..79622df 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -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", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 4406ec8..443cc29 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -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",