mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
committed by
Zyy
co-authored by
Codex
Zyy
parent
53a09bbfe9
commit
242ffb2324
@@ -1201,6 +1201,9 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota`
|
||||
|
||||
最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。
|
||||
允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。
|
||||
管理端刷新目录只更新目录,不修改模型限制。白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
|
||||
由管理员确认后从当前表单移除,保存账号后生效。目录未就绪、刷新失败或返回空目录时不能清理,清理也不能将白名单置空。
|
||||
图像、搜索、插件适配模型和映射目标可能不在目录中,目录缺失本身不代表模型失效。
|
||||
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
|
||||
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user