From 53dee11a10dce0fbd51fcc81e27a514f3847d170 Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 11 Oct 2025 22:15:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20droid=E7=9A=84apikey=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=A4=9A=E7=A7=8D=E6=9B=B4=E6=96=B0=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/droidAccountService.js | 110 +++++++++-- .../src/components/accounts/AccountForm.vue | 184 +++++++++++++++--- 2 files changed, 253 insertions(+), 41 deletions(-) diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js index 08251ccd..7217ab58 100644 --- a/src/services/droidAccountService.js +++ b/src/services/droidAccountService.js @@ -932,7 +932,26 @@ class DroidAccountService { : '' ) const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : [] + const removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : [] const wantsClearApiKeys = Boolean(updates.clearApiKeys) + const rawApiKeyMode = + typeof updates.apiKeyUpdateMode === 'string' + ? updates.apiKeyUpdateMode.trim().toLowerCase() + : '' + + let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode) + ? rawApiKeyMode + : '' + + if (!apiKeyUpdateMode) { + if (wantsClearApiKeys) { + apiKeyUpdateMode = 'replace' + } else if (removeApiKeysInput.length > 0) { + apiKeyUpdateMode = 'delete' + } else { + apiKeyUpdateMode = 'append' + } + } if (sanitizedUpdates.apiKeys !== undefined) { delete sanitizedUpdates.apiKeys @@ -940,33 +959,94 @@ class DroidAccountService { if (sanitizedUpdates.clearApiKeys !== undefined) { delete sanitizedUpdates.clearApiKeys } + if (sanitizedUpdates.apiKeyUpdateMode !== undefined) { + delete sanitizedUpdates.apiKeyUpdateMode + } + if (sanitizedUpdates.removeApiKeys !== undefined) { + delete sanitizedUpdates.removeApiKeys + } - if (wantsClearApiKeys || newApiKeysInput.length > 0) { - const mergedApiKeys = this._buildApiKeyEntries( + let mergedApiKeys = existingApiKeyEntries + let apiKeysUpdated = false + let addedCount = 0 + let removedCount = 0 + + if (apiKeyUpdateMode === 'delete') { + const removalHashes = new Set() + + for (const candidate of removeApiKeysInput) { + if (typeof candidate !== 'string') { + continue + } + const trimmed = candidate.trim() + if (!trimmed) { + continue + } + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + removalHashes.add(hash) + } + + if (removalHashes.size > 0) { + mergedApiKeys = existingApiKeyEntries.filter( + (entry) => entry && entry.hash && !removalHashes.has(entry.hash) + ) + removedCount = existingApiKeyEntries.length - mergedApiKeys.length + apiKeysUpdated = removedCount > 0 + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 删除模式未匹配任何 Droid API Key: ${accountId} (提供 ${removalHashes.size} 条)` + ) + } + } else if (removeApiKeysInput.length > 0) { + logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`) + } + } else { + const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys + const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length + + mergedApiKeys = this._buildApiKeyEntries( newApiKeysInput, existingApiKeyEntries, - wantsClearApiKeys + clearExisting ) - const baselineCount = wantsClearApiKeys ? 0 : existingApiKeyEntries.length - const addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) + addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) + apiKeysUpdated = clearExisting || addedCount > 0 + } + if (apiKeysUpdated) { sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : '' sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length) + if (apiKeyUpdateMode === 'delete') { + logger.info( + `🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}` + ) + } else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) { + logger.info( + `🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } else { + logger.info( + `🔑 追加模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } + if (mergedApiKeys.length > 0) { sanitizedUpdates.authenticationMethod = 'api_key' sanitizedUpdates.status = sanitizedUpdates.status || 'active' - logger.info( - `🔑 Updated Droid API keys for ${accountId}: total ${mergedApiKeys.length} (added ${addedCount})` - ) - } else { - logger.info(`🔑 Cleared all API keys for Droid account ${accountId}`) - // 如果完全移除 API Key,可根据是否仍有 token 来确定认证方式 - if (!sanitizedUpdates.accessToken && !account.accessToken) { - sanitizedUpdates.authenticationMethod = - account.authenticationMethod === 'api_key' ? '' : account.authenticationMethod - } + } else if (!sanitizedUpdates.accessToken && !account.accessToken) { + const shouldPreserveApiKeyMode = + account.authenticationMethod && + account.authenticationMethod.toLowerCase().trim() === 'api_key' && + (apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete') + + sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode + ? 'api_key' + : account.authenticationMethod === 'api_key' + ? '' + : account.authenticationMethod } } diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 58ae7f32..960ae79e 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -2928,10 +2928,10 @@

当前已保存 {{ existingApiKeyCount }} 条 API Key。您可以追加新的 - Key 或使用下方选项清空后重新填写。 + Key,或通过下方模式快速覆盖、删除指定 Key。

- 留空表示保留现有 Key 不变;填写内容后将覆盖或追加(视清空选项而定)。 + 留空表示保留现有 Key 不变;根据所选模式决定是追加、覆盖还是删除输入的 Key。

@@ -2945,7 +2945,7 @@ v-model="form.apiKeysInput" class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" :class="{ 'border-red-500': errors.apiKeys }" - placeholder="留空表示不更新;每行一个 API Key" + placeholder="根据模式填写;每行一个 API Key" rows="6" />

@@ -2953,16 +2953,41 @@

- +
+
+ API Key 更新模式 + + {{ currentApiKeyModeLabel }} + +
+
+ + +
+

+ {{ currentApiKeyModeDescription }} +

+
小提示

  • 系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。
  • -
  • 勾选“清空”后保存即彻底移除旧 Key,可用于紧急轮换或封禁处理。
  • +
  • 追加模式会保留现有 Key 并在末尾追加新的 Key。
  • +
  • 覆盖模式会先清空旧 Key 再写入上方的新列表。
  • +
  • 删除模式会根据输入精准移除指定 Key,适合快速处理失效或被封禁的 Key。
@@ -3283,7 +3310,7 @@ const form = ref({ accessToken: '', refreshToken: '', apiKeysInput: '', - clearExistingApiKeys: false, + apiKeyUpdateMode: 'append', proxy: initProxyConfig(), // Claude Console 特定字段 apiUrl: props.account?.apiUrl || '', @@ -3397,6 +3424,47 @@ const parseApiKeysInput = (input) => { return uniqueKeys } +const apiKeyModeOptions = [ + { + value: 'append', + label: '追加模式', + description: '保留现有 Key,并在末尾追加新 Key 列表。' + }, + { + value: 'replace', + label: '覆盖模式', + description: '先清空旧 Key,再写入上方的新 Key 列表。' + }, + { + value: 'delete', + label: '删除模式', + description: '输入要移除的 Key,可精准删除失效或被封禁的 Key。' + } +] + +const apiKeyModeSliderStyle = computed(() => { + const index = Math.max( + apiKeyModeOptions.findIndex((option) => option.value === form.value.apiKeyUpdateMode), + 0 + ) + const widthPercent = 100 / apiKeyModeOptions.length + + return { + width: `${widthPercent}%`, + left: `${index * widthPercent}%` + } +}) + +const currentApiKeyModeLabel = computed(() => { + const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode) + return option ? option.label : apiKeyModeOptions[0].label +}) + +const currentApiKeyModeDescription = computed(() => { + const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode) + return option ? option.description : apiKeyModeOptions[0].description +}) + // 表单验证错误 const errors = ref({ name: '', @@ -4313,19 +4381,40 @@ const updateAccount = async () => { if (props.account.platform === 'droid') { const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || '' + const apiKeyUpdateMode = form.value.apiKeyUpdateMode || 'append' - if (trimmedApiKeysInput) { - const apiKeys = parseApiKeysInput(trimmedApiKeysInput) - if (apiKeys.length === 0) { - errors.value.apiKeys = '请至少填写一个 API Key' + if (apiKeyUpdateMode === 'delete') { + if (!trimmedApiKeysInput) { + errors.value.apiKeys = '请填写需要删除的 API Key' loading.value = false return } - data.apiKeys = apiKeys - } - if (form.value.clearExistingApiKeys) { - data.clearApiKeys = true + const removeApiKeys = parseApiKeysInput(trimmedApiKeysInput) + if (removeApiKeys.length === 0) { + errors.value.apiKeys = '请填写需要删除的 API Key' + loading.value = false + return + } + + data.removeApiKeys = removeApiKeys + data.apiKeyUpdateMode = 'delete' + } else { + if (trimmedApiKeysInput) { + const apiKeys = parseApiKeysInput(trimmedApiKeysInput) + if (apiKeys.length === 0) { + errors.value.apiKeys = '请至少填写一个 API Key' + loading.value = false + return + } + data.apiKeys = apiKeys + } else if (apiKeyUpdateMode === 'replace') { + data.apiKeys = [] + } + + if (apiKeyUpdateMode !== 'append' || trimmedApiKeysInput) { + data.apiKeyUpdateMode = apiKeyUpdateMode + } } if (isEditingDroidApiKey.value) { @@ -4674,10 +4763,11 @@ watch( errors.value.accessToken = '' errors.value.refreshToken = '' form.value.authenticationMethod = 'api_key' + form.value.apiKeyUpdateMode = 'append' } else if (oldType === 'apikey') { // 切换离开 API Key 模式时重置 API Key 输入 form.value.apiKeysInput = '' - form.value.clearExistingApiKeys = false + form.value.apiKeyUpdateMode = 'append' errors.value.apiKeys = '' if (!isEdit.value) { form.value.authenticationMethod = '' @@ -4686,6 +4776,20 @@ watch( } ) +// 监听 API Key 更新模式切换,自动清理提示 +watch( + () => form.value.apiKeyUpdateMode, + (newMode, oldMode) => { + if (newMode === oldMode) { + return + } + + if (errors.value.apiKeys) { + errors.value.apiKeys = '' + } + } +) + // 监听 API Key 输入,自动清理错误提示 watch( () => form.value.apiKeysInput, @@ -4694,7 +4798,22 @@ watch( return } - if (parseApiKeysInput(newValue).length > 0) { + const parsed = parseApiKeysInput(newValue) + const mode = form.value.apiKeyUpdateMode + + if (mode === 'append' && parsed.length > 0) { + errors.value.apiKeys = '' + return + } + + if (mode === 'replace') { + if (parsed.length > 0 || !newValue || newValue.trim() === '') { + errors.value.apiKeys = '' + } + return + } + + if (mode === 'delete' && parsed.length > 0) { errors.value.apiKeys = '' } } @@ -4830,6 +4949,16 @@ watch( initModelMappings() // 重新初始化代理配置 const proxyConfig = normalizeProxyFormState(newAccount.proxy) + const normalizedAuthMethod = + typeof newAccount.authenticationMethod === 'string' + ? newAccount.authenticationMethod.trim().toLowerCase() + : '' + const derivedAddType = + normalizedAuthMethod === 'api_key' + ? 'apikey' + : normalizedAuthMethod === 'manual' + ? 'manual' + : 'oauth' // 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象 let groupId = '' @@ -4858,7 +4987,7 @@ watch( form.value = { platform: newAccount.platform, - addType: 'oauth', + addType: derivedAddType, name: newAccount.name, description: newAccount.description || '', accountType: newAccount.accountType || 'shared', @@ -4872,6 +5001,9 @@ watch( projectId: newAccount.projectId || '', accessToken: '', refreshToken: '', + authenticationMethod: newAccount.authenticationMethod || '', + apiKeysInput: '', + apiKeyUpdateMode: 'append', proxy: proxyConfig, // Claude Console 特定字段 apiUrl: newAccount.apiUrl || '',