feat(accounts): add a separate model allowlist cleanup button (#600)

* feat(accounts): add manual allowlist cleanup

Co-authored-by: Codex <noreply@openai.com>

* fix(accounts): refine model allowlist cleanup interactions

Co-authored-by: Codex <noreply@openai.com>

* chore(docs): remove PR screenshot assets

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
This commit is contained in:
Ke Wang
2026-11-04 17:15:04 +08:00
committed by Zyy
co-authored by Codex Zyy
parent 53a09bbfe9
commit 242ffb2324
6 changed files with 194 additions and 14 deletions
+3
View File
@@ -1201,6 +1201,9 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota`
最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。
允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。
管理端刷新目录只更新目录,不修改模型限制。白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
由管理员确认后从当前表单移除,保存账号后生效。目录未就绪、刷新失败或返回空目录时不能清理,清理也不能将白名单置空。
图像、搜索、插件适配模型和映射目标可能不在目录中,目录缺失本身不代表模型失效。
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则
+7 -2
View File
@@ -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",
@@ -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": "查看账号备注",
@@ -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": "查看賬號備註",
@@ -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<AccountModelAccess | undefined>({ required: true })
const formItem = useTemplateRef<InstanceType<typeof ZFormItem>>('modelAccessItem')
const refreshButton = useTemplateRef<InstanceType<typeof ZIconButton>>('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, () => {
</template>
<div class="flex min-w-0 items-center justify-between gap-2">
<ZSegmented v-model="mode" class="w-80 min-w-0 max-w-full" :aria-label="t('accounts.modelAccess.modelRestrictionMode')" :options="modes" :disabled="disabled" />
<ZIconButton v-if="restricted && accountId" :aria-label="t('accounts.modelAccess.refreshModel')" variant="plain" :loading="loading" :disabled="disabled" @click="load(true)">
<template #loading>
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</ZIconButton>
<div v-if="restricted && accountId" class="flex shrink-0 items-center gap-1">
<AccountModelCleanupPopover v-if="mode === 'allowlist'" :models="missingModels" :model-count="model?.models.length ?? 0" :disabled="disabled || !missingModels.length" @remove="removeMissingModels" @closed="restoreCleanupFocus" />
<ZIconButton ref="refreshButton" :aria-label="t('accounts.modelAccess.refreshModel')" variant="plain" :loading="loading" :disabled="disabled" @click="load(true)">
<template #loading>
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</ZIconButton>
</div>
</div>
</ZFormItem>
<template v-if="restricted">
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ZButton, ZCheckbox, ZIconButton, ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { BrushCleaning } from '@lucide/vue'
import { useEventListener } from '@vueuse/core'
import { computed, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
models: string[]
modelCount: number
disabled: boolean
}>()
const emit = defineEmits<{
remove: [ids: string[]]
closed: []
}>()
const { t } = useI18n({ useScope: 'global' })
const open = shallowRef(false)
const content = useTemplateRef<HTMLDivElement>('content')
useEventListener(content, 'keydown', handleKeydown)
const selected = ref<string[]>([])
const selectedModels = computed(() => props.models.filter(id => selected.value.includes(id)))
const wouldEmpty = computed(() => selectedModels.value.length === props.modelCount)
watch(open, () => {
selected.value = []
})
watch([
() => props.models,
() => props.disabled,
], () => {
open.value = false
})
function select(id: string, checked: boolean) {
selected.value = checked
? [...selected.value, id]
: selected.value.filter(value => value !== id)
}
function remove() {
if (props.disabled || !selectedModels.value.length || wouldEmpty.value)
return
emit('remove', selectedModels.value)
open.value = false
}
function handleKeydown(event: KeyboardEvent) {
if (!open.value)
return
if (event.key === 'Escape') {
// 先关闭清理浮层,避免丢弃外层账号表单的未保存修改
event.preventDefault()
event.stopPropagation()
open.value = false
}
if (event.key === 'Tab' && content.value?.contains(event.target as Node)) {
// 清理是独立的选择步骤,焦点不能穿过传送到 body 的面板进入外层表单
const controls = [
...content.value.querySelectorAll<HTMLElement>('input:not(:disabled), button:not(:disabled), [tabindex="0"]'),
]
const first = controls[0]
const last = controls.at(-1)
if (event.shiftKey
? document.activeElement === first
: document.activeElement === last) {
event.preventDefault()
;(event.shiftKey ? last : first)?.focus()
}
}
}
function focusSelection() {
content.value?.querySelector<HTMLInputElement>('input')?.focus()
}
</script>
<template>
<ZPopover v-model="open" placement="bottom-end" :disabled="disabled" @opened="focusSelection" @closed="emit('closed')">
<template #reference="{ id }">
<ZIconButton
variant="plain"
:aria-label="t('accounts.modelAccess.cleanupModels')"
:aria-expanded="open"
:aria-controls="open ? id : undefined"
aria-haspopup="dialog"
:pressed="open"
:disabled="disabled"
@keydown="handleKeydown"
>
<BrushCleaning class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
<div ref="content" class="flex max-h-[calc(100dvh-1.5rem)] w-72 max-w-[calc(100vw-2rem)] flex-col gap-3 overflow-y-auto p-3" role="dialog" :aria-label="t('accounts.modelAccess.cleanupModels')" tabindex="-1">
<p class="m-0 shrink-0 text-z-sm font-emphasis text-z-text">
{{ t('accounts.modelAccess.cleanupModels') }}
</p>
<ZScrollbar max-height="12rem" class="flex min-h-0 flex-col">
<div class="grid gap-2" role="group" :aria-label="t('accounts.modelAccess.cleanupSelection')">
<ZCheckbox
v-for="id in models"
:key="id"
:model-value="selected.includes(id)"
:label="id"
:title="id"
class="min-w-0"
@update:model-value="select(id, $event)"
>
<template #label>
<span class="block break-all">{{ id }}</span>
</template>
</ZCheckbox>
</div>
</ZScrollbar>
<p v-if="wouldEmpty" class="m-0 shrink-0 text-z-xs text-z-warning-text" role="status">
{{ t('accounts.modelAccess.cleanupKeepOne') }}
</p>
<div class="flex shrink-0 justify-end gap-2">
<ZButton size="small" @click="open = false">
{{ t('common.actions.cancel') }}
</ZButton>
<ZButton size="small" type="primary" :disabled="disabled || !selectedModels.length || wouldEmpty" @click="remove">
{{ selectedModels.length ? t('accounts.modelAccess.cleanupSelected', { count: selectedModels.length }) : t('accounts.modelAccess.cleanupRemove') }}
</ZButton>
</div>
</div>
</ZPopover>
</template>