diff --git a/docs/api.md b/docs/api.md index a5d3d382a..33003f8e8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1201,6 +1201,9 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota` 最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。 允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。 +管理端刷新目录只更新目录,不修改模型限制。白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项, +由管理员确认后从当前表单移除,保存账号后生效。目录未就绪、刷新失败或返回空目录时不能清理,清理也不能将白名单置空。 +图像、搜索、插件适配模型和映射目标可能不在目录中,目录缺失本身不代表模型失效。 例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流; Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则 diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index 4af3dc75f..4da5e6e68 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -196,14 +196,19 @@ "searchOrEnterIdAndPressEnter": "Search or enter ID and press Enter to add", "selectModel": "Select model", "notInCurrentModelCatalog": "{id} (not in the current model catalog)", - "modelCatalogMissingHint": "The current upstream catalog does not list this model, your selection has been kept", + "modelCatalogMissingHint": "The current upstream catalog does not list this model", "loadingModel": "Loading model", "noMatchingModels": "No matching models", "noModels": "No models", "selectOrAddAtLeastOneModel": "Please select or add at least one model", "configureUpToModels": "Configure up to 256 models", "invalidModelId": "Enter an exact model ID, wildcards are not supported", - "enterValidExactModelIdUpTo": "Please enter a valid exact model ID, up to 256 bytes, no wildcards are supported" + "enterValidExactModelIdUpTo": "Please enter a valid exact model ID, up to 256 bytes, no wildcards are supported", + "cleanupModels": "Clean up unlisted models", + "cleanupSelection": "Select models to remove from the allowlist", + "cleanupKeepOne": "Keep at least one model in the allowlist", + "cleanupRemove": "Remove", + "cleanupSelected": "Remove ({count})" }, "notes": { "viewAccountNotes": "View account notes", diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index 7072331c3..3e1b54019 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -196,14 +196,19 @@ "searchOrEnterIdAndPressEnter": "搜索或输入 ID,回车添加", "selectModel": "选择模型", "notInCurrentModelCatalog": "{id}(当前目录未返回)", - "modelCatalogMissingHint": "当前上游目录未返回此模型,已保留你的选择", + "modelCatalogMissingHint": "当前上游目录未返回此模型", "loadingModel": "加载模型中", "noMatchingModels": "无匹配模型", "noModels": "暂无模型", "selectOrAddAtLeastOneModel": "请至少选择或添加一个模型", "configureUpToModels": "最多配置 256 个模型", "invalidModelId": "请使用有效的精确模型 ID,不支持通配符", - "enterValidExactModelIdUpTo": "请输入有效的精确模型 ID,最多 256 字节,不支持通配符" + "enterValidExactModelIdUpTo": "请输入有效的精确模型 ID,最多 256 字节,不支持通配符", + "cleanupModels": "清理未列出模型", + "cleanupSelection": "选择要从白名单移除的模型", + "cleanupKeepOne": "请至少保留一个白名单模型", + "cleanupRemove": "移除", + "cleanupSelected": "移除 {count} 项" }, "notes": { "viewAccountNotes": "查看账号备注", diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index 011a5ed45..33b0b3658 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -196,14 +196,19 @@ "searchOrEnterIdAndPressEnter": "搜索或輸入 ID,回車添加", "selectModel": "選擇模型", "notInCurrentModelCatalog": "{id}(當前目錄未返回)", - "modelCatalogMissingHint": "當前上游目錄未返回此模型,已保留你的選擇", + "modelCatalogMissingHint": "當前上游目錄未返回此模型", "loadingModel": "加載模型中", "noMatchingModels": "無匹配模型", "noModels": "暫無模型", "selectOrAddAtLeastOneModel": "請至少選擇或添加一個模型", "configureUpToModels": "最多配置 256 個模型", "invalidModelId": "請使用有效的精確模型 ID,不支持通配符", - "enterValidExactModelIdUpTo": "請輸入有效的精確模型 ID,最多 256 字節,不支持通配符" + "enterValidExactModelIdUpTo": "請輸入有效的精確模型 ID,最多 256 字節,不支持通配符", + "cleanupModels": "清理未列出模型", + "cleanupSelection": "選擇要從白名單移除的模型", + "cleanupKeepOne": "請至少保留一個白名單模型", + "cleanupRemove": "移除", + "cleanupSelected": "移除 {count} 項" }, "notes": { "viewAccountNotes": "查看賬號備註", diff --git a/frontend/src/views/accounts/components/AccountModelAccessField.vue b/frontend/src/views/accounts/components/AccountModelAccessField.vue index 66700e51b..86b2a84b4 100644 --- a/frontend/src/views/accounts/components/AccountModelAccessField.vue +++ b/frontend/src/views/accounts/components/AccountModelAccessField.vue @@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n' import { getAccountModels, refreshAccountModels } from '@/api' import { useRequestState } from '@/composables/useRequestState' import { accountModelAccessError, accountModelIdError } from '../utils/modelAccess' +import AccountModelCleanupPopover from './AccountModelCleanupPopover.vue' const props = withDefaults(defineProps<{ accountId?: string @@ -21,6 +22,7 @@ const { t } = useI18n({ useScope: 'global' }) const model = defineModel({ required: true }) const formItem = useTemplateRef>('modelAccessItem') +const refreshButton = useTemplateRef>('refreshButton') watch(model, () => { void formItem.value?.validate('change').catch(() => undefined) }) @@ -73,15 +75,22 @@ const mode = computed({ }) const restricted = computed(() => mode.value === 'allowlist' || mode.value === 'denylist') const canAdd = computed(() => Boolean(search.value.trim()) && !model.value?.models.includes(search.value.trim())) +const catalogReady = computed(() => catalog.value !== undefined && !loading.value && !error.value) +const missingModels = computed(() => { + // 空目录和失败不能作为批量清理依据,目录缺失也不等于独立端点或适配模型无效 + if (mode.value !== 'allowlist' || !catalogReady.value || !catalog.value?.length) + return [] + const ids = new Set(catalog.value.map(item => item.id)) + return (model.value?.models ?? []).filter(id => !ids.has(id)) +}) const models = computed(() => { - const catalogReady = catalog.value !== undefined && !loading.value && !error.value const entries = new Map((catalog.value ?? []).map(item => [ item.id, { ...item, unavailable: false }, ])) for (const id of model.value?.models ?? []) { if (!entries.has(id)) - entries.set(id, { id, label: id, unavailable: catalogReady }) + entries.set(id, { id, label: id, unavailable: catalogReady.value }) } const query = search.value.trim().toLowerCase() return [ @@ -89,6 +98,28 @@ const models = computed(() => { ].filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query)) }) +function removeMissingModels(ids: string[]) { + if (props.disabled || model.value?.mode !== 'allowlist') + return + const selected = new Set(ids) + const removed = new Set(missingModels.value.filter(id => selected.has(id))) + if (!removed.size) + return + const next = { + ...model.value, + models: model.value.models.filter(id => !removed.has(id)), + } + inputError.value = accountModelAccessError(next) ?? '' + if (!inputError.value) + model.value = next +} + +function restoreCleanupFocus() { + // 最后一项清理后入口禁用,改回相邻刷新按钮,避免焦点落到页面背景 + if (!missingModels.value.length && document.activeElement === document.body) + refreshButton.value?.focus() +} + function select(id: string, selected: boolean) { if (props.disabled || !model.value || !restricted.value) return @@ -207,12 +238,15 @@ watch(search, () => {
- - - - +
+ + + + + +