mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-02 07:25:26 +08:00
Merge pull request #279 from CJackHwang/codex/add-api-key-name-handling-in-webui
feat(account): add structured API key and account name/remark support
This commit is contained in:
30
API.en.md
30
API.en.md
@@ -130,7 +130,8 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
|
||||
| POST | `/admin/settings/password` | Admin | Update admin password and invalidate old JWTs |
|
||||
| POST | `/admin/config/import` | Admin | Import config (merge/replace) |
|
||||
| GET | `/admin/config/export` | Admin | Export full config (`config`/`json`/`base64`) |
|
||||
| POST | `/admin/keys` | Admin | Add API key |
|
||||
| POST | `/admin/keys` | Admin | Add API key (optional `name`/`remark`) |
|
||||
| PUT | `/admin/keys/{key}` | Admin | Update API key metadata |
|
||||
| DELETE | `/admin/keys/{key}` | Admin | Delete API key |
|
||||
| GET | `/admin/proxies` | Admin | List proxies |
|
||||
| POST | `/admin/proxies` | Admin | Add proxy |
|
||||
@@ -647,11 +648,15 @@ Returns Vercel preconfiguration status.
|
||||
|
||||
### `GET /admin/config`
|
||||
|
||||
Returns sanitized config.
|
||||
Returns sanitized config, including both `keys` and `api_keys`.
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": ["k1", "k2"],
|
||||
"api_keys": [
|
||||
{"key": "k1", "name": "Primary", "remark": "Production"},
|
||||
{"key": "k2", "name": "Backup", "remark": "Load test"}
|
||||
],
|
||||
"env_backed": false,
|
||||
"env_source_present": true,
|
||||
"env_writeback_enabled": true,
|
||||
@@ -675,13 +680,18 @@ Returns sanitized config.
|
||||
|
||||
### `POST /admin/config`
|
||||
|
||||
Only updates `keys`, `accounts`, and `claude_mapping`.
|
||||
Only updates `keys`, `api_keys`, `accounts`, and `claude_mapping`.
|
||||
If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback.
|
||||
|
||||
**Request**:
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": ["k1", "k2"],
|
||||
"api_keys": [
|
||||
{"key": "k1", "name": "Primary", "remark": "Production"},
|
||||
{"key": "k2", "name": "Backup", "remark": "Load test"}
|
||||
],
|
||||
"accounts": [
|
||||
{"email": "user@example.com", "password": "pwd", "token": ""}
|
||||
],
|
||||
@@ -741,7 +751,7 @@ Imports full config with:
|
||||
|
||||
The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`.
|
||||
Query params `?mode=merge` / `?mode=replace` are also supported.
|
||||
Import accepts `keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored.
|
||||
Import accepts `keys`, `api_keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored.
|
||||
|
||||
> `compat` fields are managed via `/admin/settings` or the config file; this import endpoint does not update `compat`.
|
||||
|
||||
@@ -752,7 +762,17 @@ Exports full config in three forms: `config`, `json`, and `base64`.
|
||||
### `POST /admin/keys`
|
||||
|
||||
```json
|
||||
{"key": "new-api-key"}
|
||||
{"key": "new-api-key", "name": "Primary", "remark": "Production"}
|
||||
```
|
||||
|
||||
**Response**: `{"success": true, "total_keys": 3}`
|
||||
|
||||
### `PUT /admin/keys/{key}`
|
||||
|
||||
Updates the `name` / `remark` of the specified API key. The path `key` is read-only and cannot be changed.
|
||||
|
||||
```json
|
||||
{"name": "Backup", "remark": "Load test"}
|
||||
```
|
||||
|
||||
**Response**: `{"success": true, "total_keys": 3}`
|
||||
|
||||
30
API.md
30
API.md
@@ -130,7 +130,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
|
||||
| POST | `/admin/settings/password` | Admin | 更新 Admin 密码并使旧 JWT 失效 |
|
||||
| POST | `/admin/config/import` | Admin | 导入配置(merge/replace) |
|
||||
| GET | `/admin/config/export` | Admin | 导出完整配置(含 `config`/`json`/`base64`) |
|
||||
| POST | `/admin/keys` | Admin | 添加 API key |
|
||||
| POST | `/admin/keys` | Admin | 添加 API key(可附 name/remark) |
|
||||
| PUT | `/admin/keys/{key}` | Admin | 更新 API key 备注信息 |
|
||||
| DELETE | `/admin/keys/{key}` | Admin | 删除 API key |
|
||||
| GET | `/admin/proxies` | Admin | 代理列表 |
|
||||
| POST | `/admin/proxies` | Admin | 添加代理 |
|
||||
@@ -648,11 +649,15 @@ data: {"type":"message_stop"}
|
||||
|
||||
### `GET /admin/config`
|
||||
|
||||
返回脱敏后的配置。
|
||||
返回脱敏后的配置,包含 `keys` 与 `api_keys`。
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": ["k1", "k2"],
|
||||
"api_keys": [
|
||||
{"key": "k1", "name": "主 Key", "remark": "生产流量"},
|
||||
{"key": "k2", "name": "备用 Key", "remark": "压测"}
|
||||
],
|
||||
"env_backed": false,
|
||||
"env_source_present": true,
|
||||
"env_writeback_enabled": true,
|
||||
@@ -676,13 +681,18 @@ data: {"type":"message_stop"}
|
||||
|
||||
### `POST /admin/config`
|
||||
|
||||
只更新 `keys`、`accounts`、`claude_mapping`。
|
||||
只更新 `keys`、`api_keys`、`accounts`、`claude_mapping`。
|
||||
如果同时发送 `api_keys` 与 `keys`,优先保留 `api_keys` 中的结构化 `name` / `remark`;`keys` 仅作为旧格式兼容回退。
|
||||
|
||||
**请求**:
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": ["k1", "k2"],
|
||||
"api_keys": [
|
||||
{"key": "k1", "name": "主 Key", "remark": "生产流量"},
|
||||
{"key": "k2", "name": "备用 Key", "remark": "压测"}
|
||||
],
|
||||
"accounts": [
|
||||
{"email": "user@example.com", "password": "pwd", "token": ""}
|
||||
],
|
||||
@@ -742,7 +752,7 @@ data: {"type":"message_stop"}
|
||||
|
||||
请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。
|
||||
也支持在查询参数里传 `?mode=merge` / `?mode=replace`。
|
||||
导入时会接受 `keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。
|
||||
导入时会接受 `keys`、`api_keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。
|
||||
|
||||
> `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。
|
||||
|
||||
@@ -753,7 +763,17 @@ data: {"type":"message_stop"}
|
||||
### `POST /admin/keys`
|
||||
|
||||
```json
|
||||
{"key": "new-api-key"}
|
||||
{"key": "new-api-key", "name": "主 Key", "remark": "生产流量"}
|
||||
```
|
||||
|
||||
**响应**:`{"success": true, "total_keys": 3}`
|
||||
|
||||
### `PUT /admin/keys/{key}`
|
||||
|
||||
更新指定 API key 的 `name` / `remark`,路径参数中的 `key` 为只读标识,不可修改。
|
||||
|
||||
```json
|
||||
{"name": "备用 Key", "remark": "压测"}
|
||||
```
|
||||
|
||||
**响应**:`{"success": true, "total_keys": 3}`
|
||||
|
||||
23
README.MD
23
README.MD
@@ -271,8 +271,17 @@ go run ./cmd/ds2api
|
||||
```json
|
||||
{
|
||||
"keys": ["your-api-key-1", "your-api-key-2"],
|
||||
"api_keys": [
|
||||
{
|
||||
"key": "your-api-key-1",
|
||||
"name": "主 Key",
|
||||
"remark": "生产流量"
|
||||
}
|
||||
],
|
||||
"accounts": [
|
||||
{
|
||||
"name": "账号 A",
|
||||
"remark": "主账号",
|
||||
"email": "user@example.com",
|
||||
"password": "your-password"
|
||||
},
|
||||
@@ -320,7 +329,8 @@ go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
- `keys`:API 访问密钥列表,客户端通过 `Authorization: Bearer <key>` 鉴权
|
||||
- `accounts`:DeepSeek 账号列表,支持 `email` 或 `mobile` 登录
|
||||
- `api_keys`:推荐使用的新结构化密钥列表,支持 `key` + `name` + `remark`(`keys` 仍兼容)
|
||||
- `accounts`:DeepSeek 账号列表,支持 `email` 或 `mobile` 登录;可额外填写 `name` / `remark` 便于管理
|
||||
- `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token);实际 token 仅在运行时内存中维护并自动刷新
|
||||
- `model_aliases`:常见模型名(如 GPT/Codex/Claude)到 DeepSeek 模型的映射
|
||||
- `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出)
|
||||
@@ -335,6 +345,8 @@ go run ./cmd/ds2api
|
||||
|
||||
### 环境变量
|
||||
|
||||
> 建议:长期维护请优先以 `config.json`(或其 Base64)为单一配置源。环境变量仅保留部署必需项;`DS2API_CONFIG_JSON` 主要用于 Vercel/无持久盘场景,后续可能进一步收敛。
|
||||
|
||||
| 变量 | 用途 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | 服务端口 | `5001` |
|
||||
@@ -363,6 +375,15 @@ go run ./cmd/ds2api
|
||||
|
||||
> 提示:当检测到 `DS2API_CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。
|
||||
|
||||
#### 必填 / 可选(按部署方式)
|
||||
|
||||
- **所有部署都必填**:`DS2API_ADMIN_KEY`
|
||||
- **配置来源二选一(推荐前者)**:
|
||||
- `config.json` 文件(推荐,持久化更直观)
|
||||
- `DS2API_CONFIG_JSON`(可选,适合 Vercel;支持 JSON 或 Base64)
|
||||
- **仅在环境变量配置模式建议开启**:`DS2API_ENV_WRITEBACK=1`(避免管理台改动重启后丢失)
|
||||
- 其余环境变量均为可选调优项。
|
||||
|
||||
## 鉴权模式
|
||||
|
||||
调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式:
|
||||
|
||||
@@ -5,14 +5,29 @@
|
||||
"your-api-key-1",
|
||||
"your-api-key-2"
|
||||
],
|
||||
"api_keys": [
|
||||
{
|
||||
"key": "your-api-key-1",
|
||||
"name": "主 API Key",
|
||||
"remark": "给 OpenAI 客户端使用"
|
||||
},
|
||||
{
|
||||
"key": "your-api-key-2",
|
||||
"name": "备用 API Key",
|
||||
"remark": "压测或临时调试"
|
||||
}
|
||||
],
|
||||
"accounts": [
|
||||
{
|
||||
"_comment": "邮箱登录方式",
|
||||
"name": "主账号",
|
||||
"remark": "优先用于生产流量",
|
||||
"email": "example1@example.com",
|
||||
"password": "your-password-1"
|
||||
},
|
||||
{
|
||||
"_comment": "邮箱登录方式 - 账号2",
|
||||
"name": "备用账号",
|
||||
"email": "example2@example.com",
|
||||
"password": "your-password-2"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
pr.Post("/config/import", h.configImport)
|
||||
pr.Get("/config/export", h.configExport)
|
||||
pr.Post("/keys", h.addKey)
|
||||
pr.Put("/keys/{key}", h.updateKey)
|
||||
pr.Delete("/keys/{key}", h.deleteKey)
|
||||
pr.Get("/proxies", h.listProxies)
|
||||
pr.Post("/proxies", h.addProxy)
|
||||
|
||||
@@ -32,6 +32,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
for _, acc := range accounts {
|
||||
id := strings.ToLower(acc.Identifier())
|
||||
if strings.Contains(id, q) ||
|
||||
strings.Contains(strings.ToLower(acc.Name), q) ||
|
||||
strings.Contains(strings.ToLower(acc.Remark), q) ||
|
||||
strings.Contains(strings.ToLower(acc.Email), q) ||
|
||||
strings.Contains(strings.ToLower(acc.Mobile), q) {
|
||||
filtered = append(filtered, acc)
|
||||
@@ -66,6 +68,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"identifier": acc.Identifier(),
|
||||
"name": acc.Name,
|
||||
"remark": acc.Remark,
|
||||
"email": acc.Email,
|
||||
"mobile": acc.Mobile,
|
||||
"proxy_id": acc.ProxyID,
|
||||
|
||||
@@ -53,15 +53,19 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
|
||||
next.Accounts = normalizeAndDedupeAccounts(next.Accounts)
|
||||
next.VercelSyncHash = c.VercelSyncHash
|
||||
next.VercelSyncTime = c.VercelSyncTime
|
||||
importedKeys = len(next.Keys)
|
||||
importedKeys = len(next.APIKeys)
|
||||
importedAccounts = len(next.Accounts)
|
||||
} else {
|
||||
existingKeys := map[string]struct{}{}
|
||||
for _, k := range next.Keys {
|
||||
existingKeys[k] = struct{}{}
|
||||
for _, item := range next.APIKeys {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
existingKeys[key] = struct{}{}
|
||||
}
|
||||
for _, k := range incoming.Keys {
|
||||
key := strings.TrimSpace(k)
|
||||
for _, item := range incoming.APIKeys {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
@@ -69,7 +73,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
existingKeys[key] = struct{}{}
|
||||
next.Keys = append(next.Keys, key)
|
||||
next.APIKeys = append(next.APIKeys, item)
|
||||
importedKeys++
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
snap := h.Store.Snapshot()
|
||||
safe := map[string]any{
|
||||
"keys": snap.Keys,
|
||||
"api_keys": snap.APIKeys,
|
||||
"accounts": []map[string]any{},
|
||||
"proxies": []map[string]any{},
|
||||
"env_backed": h.Store.IsEnvBacked(),
|
||||
@@ -37,6 +38,8 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
accounts = append(accounts, map[string]any{
|
||||
"identifier": acc.Identifier(),
|
||||
"name": acc.Name,
|
||||
"remark": acc.Remark,
|
||||
"email": acc.Email,
|
||||
"mobile": acc.Mobile,
|
||||
"proxy_id": acc.ProxyID,
|
||||
|
||||
@@ -19,8 +19,17 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
old := h.Store.Snapshot()
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
if keys, ok := toStringSlice(req["keys"]); ok {
|
||||
c.Keys = keys
|
||||
if apiKeys, ok := toAPIKeys(req["api_keys"]); ok {
|
||||
c.APIKeys = apiKeys
|
||||
} else if keys, ok := toStringSlice(req["keys"]); ok {
|
||||
legacy := make([]config.APIKey, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
legacy = append(legacy, config.APIKey{Key: key})
|
||||
}
|
||||
c.APIKeys = legacy
|
||||
}
|
||||
if accountsRaw, ok := req["accounts"].([]any); ok {
|
||||
existing := map[string]config.Account{}
|
||||
@@ -78,17 +87,19 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
key, _ := req["key"].(string)
|
||||
key = strings.TrimSpace(key)
|
||||
name := fieldString(req, "name")
|
||||
remark := fieldString(req, "remark")
|
||||
if key == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Key 不能为空"})
|
||||
return
|
||||
}
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for _, k := range c.Keys {
|
||||
if k == key {
|
||||
for _, item := range c.APIKeys {
|
||||
if item.Key == key {
|
||||
return fmt.Errorf("key 已存在")
|
||||
}
|
||||
}
|
||||
c.Keys = append(c.Keys, key)
|
||||
c.APIKeys = append(c.APIKeys, config.APIKey{Key: key, Name: name, Remark: remark})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -98,12 +109,25 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := chi.URLParam(r, "key")
|
||||
func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
if key == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "key 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
idx := -1
|
||||
for i, k := range c.Keys {
|
||||
if k == key {
|
||||
for i, item := range c.APIKeys {
|
||||
if item.Key == key {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
@@ -111,7 +135,35 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("key 不存在")
|
||||
}
|
||||
c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...)
|
||||
if nameOK {
|
||||
c.APIKeys[idx].Name = name
|
||||
}
|
||||
if remarkOK {
|
||||
c.APIKeys[idx].Remark = remark
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := chi.URLParam(r, "key")
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
idx := -1
|
||||
for i, item := range c.APIKeys {
|
||||
if item.Key == key {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("key 不存在")
|
||||
}
|
||||
c.APIKeys = append(c.APIKeys[:idx], c.APIKeys[idx+1:]...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -129,17 +181,31 @@ func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
importedKeys, importedAccounts := 0, 0
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
if apiKeys, ok := toAPIKeys(req["api_keys"]); ok {
|
||||
existing := map[string]bool{}
|
||||
for _, item := range c.APIKeys {
|
||||
existing[item.Key] = true
|
||||
}
|
||||
for _, item := range apiKeys {
|
||||
if item.Key == "" || existing[item.Key] {
|
||||
continue
|
||||
}
|
||||
c.APIKeys = append(c.APIKeys, item)
|
||||
existing[item.Key] = true
|
||||
importedKeys++
|
||||
}
|
||||
}
|
||||
if keys, ok := req["keys"].([]any); ok {
|
||||
existing := map[string]bool{}
|
||||
for _, k := range c.Keys {
|
||||
existing[k] = true
|
||||
for _, item := range c.APIKeys {
|
||||
existing[item.Key] = true
|
||||
}
|
||||
for _, k := range keys {
|
||||
key := strings.TrimSpace(fmt.Sprintf("%v", k))
|
||||
if key == "" || existing[key] {
|
||||
continue
|
||||
}
|
||||
c.Keys = append(c.Keys, key)
|
||||
c.APIKeys = append(c.APIKeys, config.APIKey{Key: key})
|
||||
existing[key] = true
|
||||
importedKeys++
|
||||
}
|
||||
|
||||
76
internal/admin/handler_keys_test.go
Normal file
76
internal/admin/handler_keys_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestKeyEndpointsPreserveStructuredMetadata(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{
|
||||
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}]
|
||||
}`)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/admin/keys", h.addKey)
|
||||
r.Put("/admin/keys/{key}", h.updateKey)
|
||||
r.Delete("/admin/keys/{key}", h.deleteKey)
|
||||
|
||||
addBody := []byte(`{"key":"k2","name":"secondary","remark":"staging"}`)
|
||||
addReq := httptest.NewRequest(http.MethodPost, "/admin/keys", bytes.NewReader(addBody))
|
||||
addRec := httptest.NewRecorder()
|
||||
r.ServeHTTP(addRec, addReq)
|
||||
if addRec.Code != http.StatusOK {
|
||||
t.Fatalf("add status=%d body=%s", addRec.Code, addRec.Body.String())
|
||||
}
|
||||
|
||||
snap := h.Store.Snapshot()
|
||||
if len(snap.APIKeys) != 2 {
|
||||
t.Fatalf("unexpected api keys after add: %#v", snap.APIKeys)
|
||||
}
|
||||
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
|
||||
t.Fatalf("existing metadata was lost after add: %#v", snap.APIKeys[0])
|
||||
}
|
||||
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
|
||||
t.Fatalf("new metadata was lost after add: %#v", snap.APIKeys[1])
|
||||
}
|
||||
|
||||
updateBody := map[string]any{
|
||||
"name": "primary-updated",
|
||||
"remark": "prod-updated",
|
||||
}
|
||||
updateBytes, _ := json.Marshal(updateBody)
|
||||
updateReq := httptest.NewRequest(http.MethodPut, "/admin/keys/k1", bytes.NewReader(updateBytes))
|
||||
updateRec := httptest.NewRecorder()
|
||||
r.ServeHTTP(updateRec, updateReq)
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String())
|
||||
}
|
||||
|
||||
snap = h.Store.Snapshot()
|
||||
if len(snap.APIKeys) != 2 {
|
||||
t.Fatalf("unexpected api keys after update: %#v", snap.APIKeys)
|
||||
}
|
||||
if snap.APIKeys[0].Key != "k1" || snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" {
|
||||
t.Fatalf("metadata update did not persist: %#v", snap.APIKeys[0])
|
||||
}
|
||||
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/keys/k1", nil)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
r.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status=%d body=%s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
|
||||
snap = h.Store.Snapshot()
|
||||
if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" {
|
||||
t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys)
|
||||
}
|
||||
if len(snap.Keys) != 1 || snap.Keys[0] != "k2" {
|
||||
t.Fatalf("unexpected legacy keys after delete: %#v", snap.Keys)
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,43 @@ func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigPreservesStructuredAPIKeysWhenBothFieldsPresent(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{
|
||||
"keys":["legacy"],
|
||||
"api_keys":[{"key":"legacy","name":"primary","remark":"prod"}],
|
||||
"accounts":[]
|
||||
}`)
|
||||
|
||||
payload := map[string]any{
|
||||
"keys": []any{"legacy", "new-key"},
|
||||
"api_keys": []any{
|
||||
map[string]any{"key": "legacy", "name": "primary-updated", "remark": "prod-updated"},
|
||||
map[string]any{"key": "new-key", "name": "secondary", "remark": "staging"},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b))
|
||||
rec := httptest.NewRecorder()
|
||||
h.updateConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
snap := h.Store.Snapshot()
|
||||
if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" {
|
||||
t.Fatalf("unexpected keys after config update: %#v", snap.Keys)
|
||||
}
|
||||
if len(snap.APIKeys) != 2 {
|
||||
t.Fatalf("unexpected api keys after config update: %#v", snap.APIKeys)
|
||||
}
|
||||
if snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" {
|
||||
t.Fatalf("structured metadata for existing key was not preserved: %#v", snap.APIKeys[0])
|
||||
}
|
||||
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
|
||||
t.Fatalf("structured metadata for new key was not preserved: %#v", snap.APIKeys[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) {
|
||||
hash := authn.HashAdminPassword("old-password")
|
||||
h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`)
|
||||
@@ -315,6 +352,40 @@ func TestConfigImportMergeAndReplace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigImportMergePreservesStructuredAPIKeys(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{
|
||||
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}]
|
||||
}`)
|
||||
|
||||
merge := map[string]any{
|
||||
"mode": "merge",
|
||||
"config": map[string]any{
|
||||
"api_keys": []any{
|
||||
map[string]any{"key": "k1", "name": "should-not-overwrite", "remark": "ignored"},
|
||||
map[string]any{"key": "k2", "name": "secondary", "remark": "staging"},
|
||||
},
|
||||
},
|
||||
}
|
||||
mergeBytes, _ := json.Marshal(merge)
|
||||
mergeReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(mergeBytes))
|
||||
mergeRec := httptest.NewRecorder()
|
||||
h.configImport(mergeRec, mergeReq)
|
||||
if mergeRec.Code != http.StatusOK {
|
||||
t.Fatalf("merge status=%d body=%s", mergeRec.Code, mergeRec.Body.String())
|
||||
}
|
||||
|
||||
snap := h.Store.Snapshot()
|
||||
if len(snap.APIKeys) != 2 {
|
||||
t.Fatalf("unexpected api keys after structured merge: %#v", snap.APIKeys)
|
||||
}
|
||||
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
|
||||
t.Fatalf("existing structured metadata was overwritten: %#v", snap.APIKeys[0])
|
||||
}
|
||||
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
|
||||
t.Fatalf("new structured metadata was lost: %#v", snap.APIKeys[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ func toAccount(m map[string]any) config.Account {
|
||||
email := fieldString(m, "email")
|
||||
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
|
||||
return config.Account{
|
||||
Name: fieldString(m, "name"),
|
||||
Remark: fieldString(m, "remark"),
|
||||
Email: email,
|
||||
Mobile: mobile,
|
||||
Password: fieldString(m, "password"),
|
||||
@@ -69,6 +71,44 @@ func toAccount(m map[string]any) config.Account {
|
||||
}
|
||||
}
|
||||
|
||||
func toAPIKeys(v any) ([]config.APIKey, bool) {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]config.APIKey, 0, len(arr))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range arr {
|
||||
switch x := item.(type) {
|
||||
case map[string]any:
|
||||
key := fieldString(x, "key")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, config.APIKey{
|
||||
Key: key,
|
||||
Name: fieldString(x, "name"),
|
||||
Remark: fieldString(x, "remark"),
|
||||
})
|
||||
default:
|
||||
key := strings.TrimSpace(fmt.Sprintf("%v", item))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, config.APIKey{Key: key})
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func fieldString(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
@@ -77,6 +117,14 @@ func fieldString(m map[string]any, key string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
func fieldStringOptional(m map[string]any, key string) (string, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v)), true
|
||||
}
|
||||
|
||||
func statusOr(v int, d int) int {
|
||||
if v == 0 {
|
||||
return d
|
||||
@@ -99,6 +147,8 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool {
|
||||
}
|
||||
|
||||
func normalizeAccountForStorage(acc config.Account) config.Account {
|
||||
acc.Name = strings.TrimSpace(acc.Name)
|
||||
acc.Remark = strings.TrimSpace(acc.Remark)
|
||||
acc.Email = strings.TrimSpace(acc.Email)
|
||||
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
|
||||
acc.ProxyID = strings.TrimSpace(acc.ProxyID)
|
||||
|
||||
@@ -17,6 +17,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
if len(c.Keys) > 0 {
|
||||
m["keys"] = c.Keys
|
||||
}
|
||||
if len(c.APIKeys) > 0 {
|
||||
m["api_keys"] = c.APIKeys
|
||||
}
|
||||
if len(c.Accounts) > 0 {
|
||||
m["accounts"] = c.Accounts
|
||||
}
|
||||
@@ -69,6 +72,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
||||
if err := json.Unmarshal(v, &c.Keys); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
}
|
||||
case "api_keys":
|
||||
if err := json.Unmarshal(v, &c.APIKeys); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
}
|
||||
case "accounts":
|
||||
if err := json.Unmarshal(v, &c.Accounts); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
@@ -130,12 +137,14 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
c.NormalizeCredentials()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) Clone() Config {
|
||||
clone := Config{
|
||||
Keys: slices.Clone(c.Keys),
|
||||
APIKeys: slices.Clone(c.APIKeys),
|
||||
Accounts: slices.Clone(c.Accounts),
|
||||
Proxies: slices.Clone(c.Proxies),
|
||||
ClaudeMapping: cloneStringMap(c.ClaudeMapping),
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||
Accounts []Account `json:"accounts,omitempty"`
|
||||
Proxies []Proxy `json:"proxies,omitempty"`
|
||||
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
|
||||
@@ -26,6 +27,8 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
@@ -33,6 +36,12 @@ type Account struct {
|
||||
ProxyID string `json:"proxy_id,omitempty"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -73,6 +82,25 @@ func (c *Config) ClearAccountTokens() {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) NormalizeCredentials() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
normalizedAPIKeys := normalizeAPIKeys(c.APIKeys)
|
||||
if len(normalizedAPIKeys) > 0 {
|
||||
c.APIKeys = normalizedAPIKeys
|
||||
c.Keys = apiKeysToStrings(c.APIKeys)
|
||||
} else {
|
||||
c.Keys = normalizeKeys(c.Keys)
|
||||
c.APIKeys = apiKeysFromStrings(c.Keys, nil)
|
||||
}
|
||||
|
||||
for i := range c.Accounts {
|
||||
c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name)
|
||||
c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark)
|
||||
}
|
||||
}
|
||||
|
||||
// DropInvalidAccounts removes accounts that cannot be addressed by admin APIs
|
||||
// (no email and no normalizable mobile). This prevents legacy token-only
|
||||
// records from becoming orphaned empty entries after token stripping.
|
||||
|
||||
@@ -529,6 +529,101 @@ func TestStoreUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreUpdateReconcilesAPIKeyMutations(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["k1"],
|
||||
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}],
|
||||
"accounts":[]
|
||||
}`)
|
||||
store := LoadStore()
|
||||
|
||||
if err := store.Update(func(cfg *Config) error {
|
||||
cfg.APIKeys = append(cfg.APIKeys, APIKey{Key: "k2", Name: "secondary", Remark: "staging"})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("add api key failed: %v", err)
|
||||
}
|
||||
|
||||
snap := store.Snapshot()
|
||||
if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" {
|
||||
t.Fatalf("unexpected keys after api key add: %#v", snap.Keys)
|
||||
}
|
||||
if len(snap.APIKeys) != 2 {
|
||||
t.Fatalf("unexpected api keys length after add: %#v", snap.APIKeys)
|
||||
}
|
||||
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
|
||||
t.Fatalf("metadata for existing key was lost: %#v", snap.APIKeys[0])
|
||||
}
|
||||
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
|
||||
t.Fatalf("metadata for new key was lost: %#v", snap.APIKeys[1])
|
||||
}
|
||||
|
||||
if err := store.Update(func(cfg *Config) error {
|
||||
cfg.APIKeys = append([]APIKey(nil), cfg.APIKeys[1:]...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("delete api key failed: %v", err)
|
||||
}
|
||||
|
||||
snap = store.Snapshot()
|
||||
if len(snap.Keys) != 1 || snap.Keys[0] != "k2" {
|
||||
t.Fatalf("unexpected keys after api key delete: %#v", snap.Keys)
|
||||
}
|
||||
if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" {
|
||||
t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreUpdateReconcilesLegacyKeyMutations(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["k1"],
|
||||
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}],
|
||||
"accounts":[]
|
||||
}`)
|
||||
store := LoadStore()
|
||||
|
||||
if err := store.Update(func(cfg *Config) error {
|
||||
cfg.Keys = append(cfg.Keys, "k2")
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("legacy key update failed: %v", err)
|
||||
}
|
||||
|
||||
snap := store.Snapshot()
|
||||
if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" {
|
||||
t.Fatalf("unexpected keys after legacy update: %#v", snap.Keys)
|
||||
}
|
||||
if len(snap.APIKeys) != 2 {
|
||||
t.Fatalf("unexpected api keys after legacy update: %#v", snap.APIKeys)
|
||||
}
|
||||
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
|
||||
t.Fatalf("metadata for preserved key was lost: %#v", snap.APIKeys[0])
|
||||
}
|
||||
if snap.APIKeys[1].Key != "k2" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" {
|
||||
t.Fatalf("new legacy key should stay metadata-free: %#v", snap.APIKeys[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) {
|
||||
cfg := Config{
|
||||
Keys: []string{"legacy-key"},
|
||||
APIKeys: []APIKey{
|
||||
{Key: "structured-key", Name: "primary", Remark: "prod"},
|
||||
},
|
||||
}
|
||||
cfg.NormalizeCredentials()
|
||||
|
||||
if len(cfg.Keys) != 1 || cfg.Keys[0] != "structured-key" {
|
||||
t.Fatalf("unexpected normalized keys: %#v", cfg.Keys)
|
||||
}
|
||||
if len(cfg.APIKeys) != 1 {
|
||||
t.Fatalf("unexpected normalized api keys: %#v", cfg.APIKeys)
|
||||
}
|
||||
if cfg.APIKeys[0].Key != "structured-key" || cfg.APIKeys[0].Name != "primary" || cfg.APIKeys[0].Remark != "prod" {
|
||||
t.Fatalf("unexpected structured api key metadata: %#v", cfg.APIKeys[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreClaudeMapping(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-chat","slow":"deepseek-reasoner"}}`)
|
||||
store := LoadStore()
|
||||
|
||||
158
internal/config/credentials.go
Normal file
158
internal/config/credentials.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c *Config) ReconcileCredentials(base Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
currKeys := normalizeKeys(c.Keys)
|
||||
currAPIKeys := normalizeAPIKeys(c.APIKeys)
|
||||
baseKeys := normalizeKeys(base.Keys)
|
||||
baseAPIKeys := normalizeAPIKeys(base.APIKeys)
|
||||
|
||||
keysChanged := !slices.Equal(currKeys, baseKeys)
|
||||
apiKeysChanged := !equalAPIKeys(currAPIKeys, baseAPIKeys)
|
||||
|
||||
if keysChanged && !apiKeysChanged {
|
||||
c.APIKeys = apiKeysFromStrings(currKeys, apiKeyMap(baseAPIKeys))
|
||||
} else {
|
||||
c.APIKeys = currAPIKeys
|
||||
}
|
||||
c.Keys = apiKeysToStrings(c.APIKeys)
|
||||
}
|
||||
|
||||
func normalizeKeys(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeAPIKeys(items []APIKey) []APIKey {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]APIKey, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, APIKey{
|
||||
Key: key,
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiKeysFromStrings(keys []string, meta map[string]APIKey) []APIKey {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]APIKey, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if item, ok := meta[key]; ok {
|
||||
out = append(out, APIKey{
|
||||
Key: key,
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
})
|
||||
continue
|
||||
}
|
||||
out = append(out, APIKey{Key: key})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiKeysToStrings(items []APIKey) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func apiKeyMap(items []APIKey) map[string]APIKey {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]APIKey, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := out[key]; ok {
|
||||
continue
|
||||
}
|
||||
out[key] = APIKey{
|
||||
Key: key,
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func equalAPIKeys(a, b []APIKey) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return slices.EqualFunc(a, b, func(x, y APIKey) bool {
|
||||
return strings.TrimSpace(x.Key) == strings.TrimSpace(y.Key) &&
|
||||
strings.TrimSpace(x.Name) == strings.TrimSpace(y.Name) &&
|
||||
strings.TrimSpace(x.Remark) == strings.TrimSpace(y.Remark)
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func LoadStoreWithError() (*Store, error) {
|
||||
|
||||
func loadStore() (*Store, error) {
|
||||
cfg, fromEnv, err := loadConfig()
|
||||
cfg.NormalizeCredentials()
|
||||
if validateErr := ValidateConfig(cfg); validateErr != nil {
|
||||
err = errors.Join(err, validateErr)
|
||||
}
|
||||
@@ -112,6 +113,7 @@ func loadConfigFromFile(path string) (Config, error) {
|
||||
if err := json.Unmarshal(content, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.NormalizeCredentials()
|
||||
cfg.DropInvalidAccounts()
|
||||
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
|
||||
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
||||
@@ -207,6 +209,7 @@ func (s *Store) UpdateAccountToken(identifier, token string) error {
|
||||
func (s *Store) Replace(cfg Config) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cfg.NormalizeCredentials()
|
||||
s.cfg = cfg.Clone()
|
||||
s.rebuildIndexes()
|
||||
return s.saveLocked()
|
||||
@@ -215,10 +218,13 @@ func (s *Store) Replace(cfg Config) error {
|
||||
func (s *Store) Update(mutator func(*Config) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cfg := s.cfg.Clone()
|
||||
base := s.cfg.Clone()
|
||||
cfg := base.Clone()
|
||||
if err := mutator(&cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.ReconcileCredentials(base)
|
||||
cfg.NormalizeCredentials()
|
||||
s.cfg = cfg
|
||||
s.rebuildIndexes()
|
||||
return s.saveLocked()
|
||||
|
||||
@@ -30,7 +30,10 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
|
||||
const {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
openAddKey,
|
||||
openEditKey,
|
||||
closeKeyModal,
|
||||
editingKey,
|
||||
showAddAccount,
|
||||
setShowAddAccount,
|
||||
newKey,
|
||||
@@ -94,7 +97,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
config={config}
|
||||
keysExpanded={keysExpanded}
|
||||
setKeysExpanded={setKeysExpanded}
|
||||
setShowAddKey={setShowAddKey}
|
||||
onAddKey={openAddKey}
|
||||
onEditKey={openEditKey}
|
||||
copiedKey={copiedKey}
|
||||
setCopiedKey={setCopiedKey}
|
||||
onDeleteKey={deleteKey}
|
||||
@@ -133,10 +137,11 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
<AddKeyModal
|
||||
show={showAddKey}
|
||||
t={t}
|
||||
editingKey={editingKey}
|
||||
newKey={newKey}
|
||||
setNewKey={setNewKey}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddKey(false)}
|
||||
onClose={closeKeyModal}
|
||||
onAdd={addKey}
|
||||
/>
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ export default function AccountsTable({
|
||||
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{acc.name || '-'}</div>
|
||||
<div
|
||||
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
|
||||
onClick={() => copyId(id)}
|
||||
@@ -128,6 +129,9 @@ export default function AccountsTable({
|
||||
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
{acc.remark && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
|
||||
{acc.token_preview && (
|
||||
|
||||
@@ -23,6 +23,26 @@ export default function AddAccountModal({
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<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={newAccount.name}
|
||||
onChange={e => setNewAccount({ ...newAccount, name: e.target.value })}
|
||||
/>
|
||||
</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={newAccount.remark}
|
||||
onChange={e => setNewAccount({ ...newAccount, remark: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('accountManager.emailOptional')}</label>
|
||||
<input
|
||||
|
||||
@@ -1,45 +1,75 @@
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClose, onAdd }) {
|
||||
export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, loading, onClose, onAdd }) {
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isEditing = Boolean(editingKey?.key)
|
||||
|
||||
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-center">
|
||||
<h3 className="font-semibold">{t('accountManager.modalAddKeyTitle')}</h3>
|
||||
<h3 className="font-semibold">{isEditing ? t('accountManager.modalEditKeyTitle') : t('accountManager.modalAddKeyTitle')}</h3>
|
||||
<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>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('accountManager.newKeyLabel')}</label>
|
||||
<label className="block text-sm font-medium mb-1.5">{isEditing ? t('accountManager.keyLabel') : t('accountManager.newKeyLabel')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input-field bg-[#09090b] flex-1"
|
||||
placeholder={t('accountManager.newKeyPlaceholder')}
|
||||
value={newKey}
|
||||
onChange={e => setNewKey(e.target.value)}
|
||||
autoFocus
|
||||
className={isEditing ? "input-field bg-muted/30 flex-1 cursor-not-allowed" : "input-field bg-[#09090b] flex-1"}
|
||||
placeholder={isEditing ? t('accountManager.keyReadonlyPlaceholder') : t('accountManager.newKeyPlaceholder')}
|
||||
value={newKey.key}
|
||||
onChange={e => setNewKey({ ...newKey, key: e.target.value })}
|
||||
autoFocus={!isEditing}
|
||||
readOnly={isEditing}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewKey('sk-' + crypto.randomUUID().replace(/-/g, ''))}
|
||||
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
|
||||
>
|
||||
{t('accountManager.generate')}
|
||||
</button>
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewKey({ ...newKey, key: 'sk-' + crypto.randomUUID().replace(/-/g, '') })}
|
||||
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
|
||||
>
|
||||
{t('accountManager.generate')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{t('accountManager.generateHint')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{isEditing ? t('accountManager.keyReadonlyHint') : t('accountManager.generateHint')}
|
||||
</p>
|
||||
</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={newKey.name}
|
||||
onChange={e => setNewKey({ ...newKey, name: e.target.value })}
|
||||
autoFocus={isEditing}
|
||||
/>
|
||||
</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={newKey.remark}
|
||||
onChange={e => setNewKey({ ...newKey, 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={onAdd} 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.addKeyLoading') : t('accountManager.addKeyAction')}
|
||||
{loading
|
||||
? (isEditing ? t('accountManager.editKeyLoading') : t('accountManager.addKeyLoading'))
|
||||
: (isEditing ? t('accountManager.editKeyAction') : t('accountManager.addKeyAction'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { Check, ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
function fallbackCopyText(text) {
|
||||
@@ -31,12 +31,16 @@ export default function ApiKeysPanel({
|
||||
config,
|
||||
keysExpanded,
|
||||
setKeysExpanded,
|
||||
setShowAddKey,
|
||||
onAddKey,
|
||||
onEditKey,
|
||||
copiedKey,
|
||||
setCopiedKey,
|
||||
onDeleteKey,
|
||||
}) {
|
||||
const [failedKey, setFailedKey] = useState(null)
|
||||
const apiKeys = Array.isArray(config?.api_keys) && config.api_keys.length > 0
|
||||
? config.api_keys
|
||||
: (config?.keys || []).map(key => ({ key, name: '', remark: '' }))
|
||||
|
||||
const handleCopyKey = async (key) => {
|
||||
try {
|
||||
@@ -74,11 +78,11 @@ export default function ApiKeysPanel({
|
||||
)} />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('accountManager.apiKeysTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})</p>
|
||||
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')} ({apiKeys.length || 0})</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAddKey(true) }}
|
||||
onClick={(e) => { e.stopPropagation(); onAddKey() }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -88,34 +92,43 @@ export default function ApiKeysPanel({
|
||||
|
||||
{keysExpanded && (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{config.keys?.length > 0 ? (
|
||||
config.keys.map((key, i) => (
|
||||
{apiKeys.length > 0 ? (
|
||||
apiKeys.map((item, i) => (
|
||||
<div key={i} className="p-4 flex items-center justify-between hover:bg-muted/50 transition-colors group">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 flex-1">
|
||||
<div className="text-sm">{item.name || '-'}</div>
|
||||
<button
|
||||
onClick={() => handleCopyKey(key)}
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block hover:bg-muted transition-colors"
|
||||
title={t('accountManager.copyKeyTitle')}
|
||||
>
|
||||
{key.slice(0, 16)}****
|
||||
{(item.key || '').slice(0, 16)}****
|
||||
</button>
|
||||
{copiedKey === key && (
|
||||
<div className="text-sm text-muted-foreground truncate">{item.remark || '-'}</div>
|
||||
{copiedKey === item.key && (
|
||||
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
|
||||
)}
|
||||
{failedKey === key && (
|
||||
{failedKey === item.key && (
|
||||
<span className="text-xs text-destructive">{t('accountManager.copyFailed')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopyKey(key)}
|
||||
onClick={() => onEditKey(item)}
|
||||
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"
|
||||
title={t('accountManager.editKeyTitle')}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"
|
||||
title={t('accountManager.copyKeyTitle')}
|
||||
>
|
||||
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
{copiedKey === item.key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteKey(key)}
|
||||
onClick={() => onDeleteKey(item.key)}
|
||||
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
title={t('accountManager.deleteKeyTitle')}
|
||||
>
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useState } from 'react'
|
||||
|
||||
export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, fetchAccounts, resolveAccountIdentifier }) {
|
||||
const [showAddKey, setShowAddKey] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState(null)
|
||||
const [showAddAccount, setShowAddAccount] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
|
||||
const [copiedKey, setCopiedKey] = useState(null)
|
||||
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
|
||||
const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [testing, setTesting] = useState({})
|
||||
const [testingAll, setTestingAll] = useState(false)
|
||||
@@ -14,23 +15,58 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
const [deletingSessions, setDeletingSessions] = useState({})
|
||||
const [updatingProxy, setUpdatingProxy] = useState({})
|
||||
|
||||
const openAddKey = () => {
|
||||
setEditingKey(null)
|
||||
setNewKey({ key: '', name: '', remark: '' })
|
||||
setShowAddKey(true)
|
||||
}
|
||||
|
||||
const openEditKey = (item) => {
|
||||
if (!item?.key) return
|
||||
setEditingKey(item)
|
||||
setNewKey({
|
||||
key: item.key || '',
|
||||
name: item.name || '',
|
||||
remark: item.remark || '',
|
||||
})
|
||||
setShowAddKey(true)
|
||||
}
|
||||
|
||||
const closeKeyModal = () => {
|
||||
setShowAddKey(false)
|
||||
setEditingKey(null)
|
||||
setNewKey({ key: '', name: '', remark: '' })
|
||||
}
|
||||
|
||||
const addKey = async () => {
|
||||
if (!newKey.trim()) return
|
||||
const isEditing = Boolean(editingKey?.key)
|
||||
if (!isEditing && !newKey.key.trim()) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await apiFetch('/admin/keys', {
|
||||
method: 'POST',
|
||||
const endpoint = isEditing
|
||||
? `/admin/keys/${encodeURIComponent(editingKey.key)}`
|
||||
: '/admin/keys'
|
||||
const method = isEditing ? 'PUT' : 'POST'
|
||||
const payload = isEditing
|
||||
? { name: newKey.name, remark: newKey.remark }
|
||||
: { key: newKey.key.trim(), name: newKey.name, remark: newKey.remark }
|
||||
if (!isEditing && !payload.key) {
|
||||
return
|
||||
}
|
||||
const res = await apiFetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: newKey.trim() }),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (res.ok) {
|
||||
onMessage('success', t('accountManager.addKeySuccess'))
|
||||
setNewKey('')
|
||||
setShowAddKey(false)
|
||||
onMessage('success', isEditing ? t('accountManager.updateKeySuccess') : t('accountManager.addKeySuccess'))
|
||||
closeKeyModal()
|
||||
onRefresh()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
onMessage('error', data.detail || t('messages.failedToAdd'))
|
||||
onMessage('error', data.detail || (isEditing ? t('messages.requestFailed') : t('messages.failedToAdd')))
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', t('messages.networkError'))
|
||||
@@ -68,7 +104,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
})
|
||||
if (res.ok) {
|
||||
onMessage('success', t('accountManager.addAccountSuccess'))
|
||||
setNewAccount({ email: '', mobile: '', password: '' })
|
||||
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
|
||||
setShowAddAccount(false)
|
||||
fetchAccounts(1)
|
||||
onRefresh()
|
||||
@@ -244,7 +280,10 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
|
||||
return {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
openAddKey,
|
||||
openEditKey,
|
||||
closeKeyModal,
|
||||
editingKey,
|
||||
showAddAccount,
|
||||
setShowAddAccount,
|
||||
newKey,
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
},
|
||||
"accountManager": {
|
||||
"addKeySuccess": "API key added successfully.",
|
||||
"updateKeySuccess": "API key updated successfully.",
|
||||
"addAccountSuccess": "Account added successfully.",
|
||||
"requiredFields": "Password and email/mobile are required.",
|
||||
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
|
||||
@@ -112,6 +113,7 @@
|
||||
"apiKeysTitle": "API Keys",
|
||||
"apiKeysDesc": "Manage the API access key pool",
|
||||
"addKey": "Add key",
|
||||
"editKeyTitle": "Edit key",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"copyKeyTitle": "Copy key",
|
||||
@@ -128,13 +130,23 @@
|
||||
"testStatusFailed": "Last test failed",
|
||||
"noAccounts": "No accounts found.",
|
||||
"modalAddKeyTitle": "Add API key",
|
||||
"modalEditKeyTitle": "Edit API key",
|
||||
"newKeyLabel": "New key value",
|
||||
"newKeyPlaceholder": "Enter a custom API key",
|
||||
"keyLabel": "Key value",
|
||||
"keyReadonlyPlaceholder": "Key value cannot be changed",
|
||||
"keyReadonlyHint": "The key value is read-only. Update the name and remark instead.",
|
||||
"generate": "Generate",
|
||||
"generateHint": "Click Generate to create a random key.",
|
||||
"addKeyLoading": "Adding...",
|
||||
"addKeyAction": "Add key",
|
||||
"editKeyLoading": "Saving...",
|
||||
"editKeyAction": "Save changes",
|
||||
"modalAddAccountTitle": "Add DeepSeek account",
|
||||
"nameOptional": "Name (optional)",
|
||||
"namePlaceholder": "e.g. Primary Account A",
|
||||
"remarkOptional": "Remark (optional)",
|
||||
"remarkPlaceholder": "e.g. Team shared / test only",
|
||||
"emailOptional": "Email (optional)",
|
||||
"mobileOptional": "Mobile (optional)",
|
||||
"passwordLabel": "Password",
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
},
|
||||
"accountManager": {
|
||||
"addKeySuccess": "API 密钥添加成功",
|
||||
"updateKeySuccess": "API 密钥更新成功",
|
||||
"addAccountSuccess": "账号添加成功",
|
||||
"requiredFields": "需要填写密码以及邮箱或手机号",
|
||||
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
|
||||
@@ -112,6 +113,7 @@
|
||||
"apiKeysTitle": "API 密钥",
|
||||
"apiKeysDesc": "管理 API 访问密钥池",
|
||||
"addKey": "添加密钥",
|
||||
"editKeyTitle": "编辑密钥",
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"copyKeyTitle": "复制密钥",
|
||||
@@ -128,13 +130,23 @@
|
||||
"testStatusFailed": "上次测试失败",
|
||||
"noAccounts": "未找到任何账号",
|
||||
"modalAddKeyTitle": "添加 API 密钥",
|
||||
"modalEditKeyTitle": "编辑 API 密钥",
|
||||
"newKeyLabel": "新密钥值",
|
||||
"newKeyPlaceholder": "输入自定义 API 密钥",
|
||||
"keyLabel": "密钥值",
|
||||
"keyReadonlyPlaceholder": "密钥值不可修改",
|
||||
"keyReadonlyHint": "密钥值不可编辑,仅可修改名称和备注。",
|
||||
"generate": "生成",
|
||||
"generateHint": "点击「生成」自动创建随机密钥",
|
||||
"addKeyLoading": "添加中...",
|
||||
"addKeyAction": "添加密钥",
|
||||
"editKeyLoading": "保存中...",
|
||||
"editKeyAction": "保存修改",
|
||||
"modalAddAccountTitle": "添加 DeepSeek 账号",
|
||||
"nameOptional": "名称(可选)",
|
||||
"namePlaceholder": "例如:主账号 A",
|
||||
"remarkOptional": "备注(可选)",
|
||||
"remarkPlaceholder": "例如:团队共享 / 仅测试用",
|
||||
"emailOptional": "邮箱 (可选)",
|
||||
"mobileOptional": "手机号 (可选)",
|
||||
"passwordLabel": "密码",
|
||||
|
||||
Reference in New Issue
Block a user