diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js
index 5994f56d..4fc0f54b 100644
--- a/src/routes/admin/apiKeys.js
+++ b/src/routes/admin/apiKeys.js
@@ -21,11 +21,28 @@ function validatePermissions(permissions) {
if (permissions === undefined || permissions === null || permissions === '') {
return null
}
- // 兼容旧格式字符串
+ // 兼容字符串格式
if (typeof permissions === 'string') {
- if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
+ // 旧格式 'all' 表示全部服务
+ if (permissions === 'all') {
return null
}
+ // 单个有效权限
+ if (VALID_PERMISSIONS.includes(permissions)) {
+ return null
+ }
+ // 尝试解析 JSON 数组字符串(如 "[]" 或 '["claude","gemini"]')
+ if (permissions.startsWith('[')) {
+ try {
+ const parsed = JSON.parse(permissions)
+ if (Array.isArray(parsed)) {
+ // 递归验证解析后的数组
+ return validatePermissions(parsed)
+ }
+ } catch (e) {
+ // 解析失败,返回错误
+ }
+ }
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
}
// 新格式数组
diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js
index 771f973b..769a7226 100644
--- a/src/services/apiKeyService.js
+++ b/src/services/apiKeyService.js
@@ -750,8 +750,13 @@ class ApiKeyService {
for (const [field, value] of Object.entries(updates)) {
if (allowedUpdates.includes(field)) {
- if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
- // 特殊处理数组字段
+ if (
+ field === 'restrictedModels' ||
+ field === 'allowedClients' ||
+ field === 'tags' ||
+ field === 'permissions'
+ ) {
+ // 特殊处理数组字段,使用 JSON.stringify
updatedData[field] = JSON.stringify(value || [])
} else if (
field === 'enableModelRestriction' ||
diff --git a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue
index 39eff929..255d1f88 100644
--- a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue
+++ b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue
@@ -290,31 +290,79 @@
-
@@ -494,6 +542,7 @@ const localAccounts = ref({
const newTag = ref('')
const availableTags = ref([])
const tagOperation = ref('none') // 'replace', 'add', 'remove', 'none'
+const permissionsOperation = ref('none') // 'none', 'all', 'custom'
const selectedCount = computed(() => props.selectedKeys.length)
@@ -511,7 +560,7 @@ const form = reactive({
dailyCostLimit: '',
totalCostLimit: '',
weeklyOpusCostLimit: '', // 新增Opus周费用限制
- permissions: '', // 空字符串表示不修改
+ permissions: [], // 数组格式,用于多选
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
@@ -547,9 +596,15 @@ const bedrockAccountSelectorValue = createAccountSelectorModel('bedrockAccountId
const droidAccountSelectorValue = createAccountSelectorModel('droidAccountId')
const isServiceSelectable = (service) => {
- if (!form.permissions) return true
- if (form.permissions === 'all') return true
- return form.permissions === service
+ // 不修改权限时,所有服务都可选
+ if (permissionsOperation.value === 'none') return true
+ // 全部服务时,所有服务都可选
+ if (permissionsOperation.value === 'all') return true
+ // 自定义选择时,根据选择的权限判断
+ if (permissionsOperation.value === 'custom') {
+ return form.permissions.length === 0 || form.permissions.includes(service)
+ }
+ return true
}
// 标签管理方法
@@ -737,8 +792,14 @@ const batchUpdateApiKeys = async () => {
}
// 权限设置
- if (form.permissions !== '') {
- updates.permissions = form.permissions
+ if (permissionsOperation.value !== 'none') {
+ if (permissionsOperation.value === 'all') {
+ // 全部服务:发送空数组
+ updates.permissions = []
+ } else if (permissionsOperation.value === 'custom') {
+ // 自定义选择:发送选中的权限数组
+ updates.permissions = form.permissions
+ }
}
// 账户绑定
diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
index 749039bf..e0cf46a7 100644
--- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
+++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
@@ -1235,11 +1235,65 @@ onMounted(async () => {
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
const perms = props.apiKey.permissions
if (Array.isArray(perms)) {
- form.permissions = perms
+ // 过滤掉损坏的数据(如 "claude,droid" 这种逗号分隔的字符串)
+ const validPerms = ['claude', 'gemini', 'openai', 'droid']
+ const cleaned = []
+ for (const p of perms) {
+ if (validPerms.includes(p)) {
+ cleaned.push(p)
+ } else if (typeof p === 'string' && p.includes(',')) {
+ // 处理逗号分隔的旧格式
+ const parts = p.split(',').map((s) => s.trim())
+ for (const part of parts) {
+ if (validPerms.includes(part) && !cleaned.includes(part)) {
+ cleaned.push(part)
+ }
+ }
+ }
+ }
+ form.permissions = cleaned
} else if (perms === 'all' || !perms) {
form.permissions = []
} else if (typeof perms === 'string') {
- form.permissions = [perms]
+ // 尝试解析 JSON 数组字符串(如 "[]" 或 '["claude","gemini"]')
+ if (perms.startsWith('[')) {
+ try {
+ const parsed = JSON.parse(perms)
+ if (Array.isArray(parsed)) {
+ // 递归处理解析后的数组
+ const validPerms = ['claude', 'gemini', 'openai', 'droid']
+ const cleaned = []
+ for (const p of parsed) {
+ if (validPerms.includes(p)) {
+ cleaned.push(p)
+ } else if (typeof p === 'string' && p.includes(',')) {
+ const parts = p.split(',').map((s) => s.trim())
+ for (const part of parts) {
+ if (validPerms.includes(part) && !cleaned.includes(part)) {
+ cleaned.push(part)
+ }
+ }
+ }
+ }
+ form.permissions = cleaned
+ } else {
+ form.permissions = []
+ }
+ } catch (e) {
+ // 解析失败,尝试按逗号分隔处理
+ const validPerms = ['claude', 'gemini', 'openai', 'droid']
+ const parts = perms.split(',').map((s) => s.trim())
+ form.permissions = parts.filter((p) => validPerms.includes(p))
+ }
+ } else if (perms.includes(',')) {
+ // 逗号分隔的旧格式
+ const validPerms = ['claude', 'gemini', 'openai', 'droid']
+ const parts = perms.split(',').map((s) => s.trim())
+ form.permissions = parts.filter((p) => validPerms.includes(p))
+ } else {
+ const validPerms = ['claude', 'gemini', 'openai', 'droid']
+ form.permissions = validPerms.includes(perms) ? [perms] : []
+ }
} else {
form.permissions = []
}