- User 新增 allowed_models/denied_models(用户级模型限制) - 系统配置 model_allowlist/model_denylist 全局策略, 保存即时失效网关缓存 - 网关 checkModelAllowed: 用户级 > 全局(禁止命中→403, 白名单非空→仅白名单) - 三个协议处理器均校验, 错误按客户端协议格式返回 - 配置页"模型限制"卡片(全局允许/禁止多选); 用户编辑支持允许/禁止模型 Co-Authored-By: Claude <noreply@anthropic.com>
165 lines
5.6 KiB
Vue
165 lines
5.6 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { http, errMsg } from '@/api/client'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import Button from '@/components/ui/Button.vue'
|
||
|
||
const toast = useToastStore()
|
||
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 {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
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 {
|
||
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))
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
load()
|
||
loadModels()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<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>
|
||
</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">
|
||
<div v-if="loading" class="py-8 text-center text-sm text-muted">加载中…</div>
|
||
<template v-else>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<p class="mb-1.5 text-xs font-medium text-muted">注册模式</p>
|
||
<div class="flex gap-2">
|
||
<button
|
||
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
|
||
:class="config.registration_mode === 'open' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
|
||
@click="config.registration_mode = 'open'"
|
||
>
|
||
开放注册
|
||
</button>
|
||
<button
|
||
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
|
||
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
|
||
@click="config.registration_mode = 'invite'"
|
||
>
|
||
邀请码
|
||
</button>
|
||
</div>
|
||
<p class="mt-1.5 text-xs text-muted">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="mb-1.5 text-xs font-medium text-muted">邀请码(逗号分隔)</p>
|
||
<input
|
||
v-model="config.invite_codes"
|
||
placeholder="code1,code2"
|
||
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>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<Button :loading="saving" @click="save">保存配置</Button>
|
||
</div>
|
||
</div>
|
||
</template>
|