模型限制: 系统配置全局开放/禁止 + 用户级限制 + 网关拦截

- User 新增 allowed_models/denied_models(用户级模型限制)
- 系统配置 model_allowlist/model_denylist 全局策略, 保存即时失效网关缓存
- 网关 checkModelAllowed: 用户级 > 全局(禁止命中→403, 白名单非空→仅白名单)
- 三个协议处理器均校验, 错误按客户端协议格式返回
- 配置页"模型限制"卡片(全局允许/禁止多选); 用户编辑支持允许/禁止模型

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-16 01:34:12 +08:00
co-authored by Claude
parent 866690b7ce
commit b365779188
9 changed files with 240 additions and 28 deletions
+85 -17
View File
@@ -9,12 +9,31 @@ const config = reactive<Record<string, string>>({})
const loading = ref(false)
const saving = ref(false)
const availableModels = ref<string[]>([])
const allowList = ref<string[]>([])
const denyList = ref<string[]>([])
function parseConfigList(v: unknown): string[] {
if (!v) return []
if (Array.isArray(v)) return v.map(String)
try {
const a = JSON.parse(String(v))
return Array.isArray(a) ? a.map(String) : []
} catch {
return []
}
}
async function load() {
loading.value = true
try {
const { data } = await http.get('/admin/config')
Object.keys(config).forEach((k) => delete config[k])
Object.assign(config, data.data.config)
allowList.value = parseConfigList(config.model_allowlist)
denyList.value = parseConfigList(config.model_denylist)
delete config.model_allowlist
delete config.model_denylist
} catch (e) {
toast.err(errMsg(e))
} finally {
@@ -22,10 +41,22 @@ async function load() {
}
}
async function loadModels() {
try {
const { data } = await http.get('/admin/models')
availableModels.value = (data.data.items as { name: string }[]).map((m) => m.name)
} catch {
/* 忽略 */
}
}
async function save() {
saving.value = true
try {
await http.put('/admin/config', config)
const payload: Record<string, unknown> = { ...config }
payload.model_allowlist = allowList.value
payload.model_denylist = denyList.value
await http.put('/admin/config', payload)
toast.ok('配置已保存')
} catch (e) {
toast.err(errMsg(e))
@@ -34,14 +65,59 @@ async function save() {
}
}
onMounted(load)
onMounted(() => {
load()
loadModels()
})
</script>
<template>
<div class="mx-auto max-w-2xl">
<div class="mb-6">
<div class="mx-auto max-w-2xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">系统配置</h1>
<p class="text-sm text-muted">注册策略等平台级配置</p>
<p class="text-sm text-muted">注册策略与模型访问限制</p>
</div>
<div class="card p-6">
<div class="mb-5">
<h2 class="text-sm font-semibold">模型限制</h2>
<p class="mt-1 text-xs text-muted">
针对全部用户开放/禁止模型;用户级限制优先级更高(用户管理里可单独配置)。
</p>
</div>
<div class="space-y-5">
<div>
<p class="mb-2 text-xs font-medium text-muted">允许的模型(留空 = 全部开放)</p>
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in availableModels"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="allowList.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
>
<input v-model="allowList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-accent)]" />
{{ m }}
</label>
<p v-if="availableModels.length === 0" class="text-xs text-muted">暂无模型,请先在模型定价中添加</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">禁止的模型(黑名单优先)</p>
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in availableModels"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="denyList.includes(m) ? 'border-err bg-err-soft text-err' : 'border-edge2 text-muted hover:border-edge'"
>
<input v-model="denyList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-err)]" />
{{ m }}
</label>
</div>
</div>
</div>
</div>
<div class="card space-y-5 p-6">
@@ -77,20 +153,12 @@ onMounted(load)
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
/>
</div>
<div class="flex items-center justify-between rounded-md border border-edge bg-surface px-4 py-3">
<div>
<p class="text-sm text-ink">其他配置项</p>
<p class="text-xs text-muted">汇率、限流阈值、维护开关在后续里程碑开放</p>
</div>
<span class="font-mono text-xs text-muted">M3+</span>
</div>
</div>
<div class="flex justify-end">
<Button :loading="saving" @click="save">保存</Button>
</div>
</template>
</div>
<div class="flex justify-end">
<Button :loading="saving" @click="save">保存配置</Button>
</div>
</div>
</template>
+40 -2
View File
@@ -21,7 +21,20 @@ const pageSize = 15
const editOpen = ref(false)
const editing = ref<User | null>(null)
const editForm = reactive({ username: '', email: '', password: '', role: 'user', status: 'active' })
const editForm = reactive({
username: '',
email: '',
password: '',
role: 'user',
status: 'active',
allowed_models: '',
denied_models: '',
})
function parseModelList(s: string): string[] | undefined {
const arr = s.split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean)
return arr.length ? arr : undefined
}
const balanceOpen = ref(false)
const balanceUser = ref<User | null>(null)
@@ -50,6 +63,8 @@ function openEdit(u: User) {
password: '',
role: u.role,
status: u.status,
allowed_models: (u.allowed_models || []).join(', '),
denied_models: (u.denied_models || []).join(', '),
})
editOpen.value = true
}
@@ -57,7 +72,18 @@ function openEdit(u: User) {
async function saveEdit() {
if (!editing.value) return
try {
await http.patch(`/admin/users/${editing.value.id}`, editForm)
const payload: Record<string, unknown> = {
username: editForm.username,
email: editForm.email,
role: editForm.role,
status: editForm.status,
}
if (editForm.password) payload.password = editForm.password
const allow = parseModelList(editForm.allowed_models)
const deny = parseModelList(editForm.denied_models)
payload.allowed_models = allow ?? []
payload.denied_models = deny ?? []
await http.patch(`/admin/users/${editing.value.id}`, payload)
toast.ok('已更新')
editOpen.value = false
await load()
@@ -187,6 +213,18 @@ onMounted(load)
autocomplete="new-password"
hint="留空则不修改"
/>
<Input
v-model="editForm.allowed_models"
label="允许的模型"
placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5"
hint="留空不限制;用户级白名单优先于全局"
/>
<Input
v-model="editForm.denied_models"
label="禁止的模型"
placeholder="逗号分隔"
hint="黑名单优先"
/>
<div class="grid grid-cols-2 gap-4">
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-muted">角色</span>