feat(admin): 优化管理后台渠道模型选择功能

- 在管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按"新获取、已有"分组选择
- 弹窗内支持手动增加模型或拉取模型列表后再写回表单
- 编辑渠道时API Key留空不再触发必填校验,表示沿用已保存的密钥
- 新增渠道仍要求填写API Key以确保安全性
- 公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成
- 最终开放哪些模型仍由公开配置里手动勾选决定
- 添加了Checkbox组件用于多选模型功能
- 实现了模型选择器的状态管理和数据过滤逻辑
This commit is contained in:
HouYunFei
2026-05-25 16:37:18 +08:00
parent d8cb1d6ed2
commit b21f8c3af9
2 changed files with 172 additions and 11 deletions
+3
View File
@@ -1,3 +1,6 @@
# 待测试
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
- 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
+169 -11
View File
@@ -2,7 +2,7 @@
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
import { json } from "@codemirror/lang-json";
import { App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd";
import { App, Button, Card, Checkbox, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { EditorView } from "@uiw/react-codemirror";
@@ -44,6 +44,7 @@ const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl:
type SettingsTabKey = "public" | "private";
type EditorMode = "visual" | "json";
type ModelSelectTabKey = "new" | "current";
export default function AdminSettingsPage() {
const token = useUserStore((state) => state.token);
@@ -61,6 +62,14 @@ export default function AdminSettingsPage() {
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
const [testingModels, setTestingModels] = useState<string[]>([]);
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false);
const [modelSelectSource, setModelSelectSource] = useState<string[]>([]);
const [modelSelectExisting, setModelSelectExisting] = useState<string[]>([]);
const [modelSelectSelected, setModelSelectSelected] = useState<string[]>([]);
const [modelSelectKeyword, setModelSelectKeyword] = useState("");
const [modelSelectNewModel, setModelSelectNewModel] = useState("");
const [modelSelectTab, setModelSelectTab] = useState<ModelSelectTabKey>("new");
const [isFetchingChannelModels, setIsFetchingChannelModels] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [modelCosts, setModelCosts] = useState<AdminModelCost[]>([]);
@@ -68,10 +77,15 @@ export default function AdminSettingsPage() {
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]);
const modelOptions = useMemo(() => uniqueModels([...knownModels, ...publicModels, ...channelModels, ...modelCosts.map((item) => item.model)]), [knownModels, publicModels, channelModels, modelCosts]);
const activeMode = editorMode[activeTab];
const activeJsonText = jsonText[activeTab];
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
const modelSelectGroups = useMemo(() => buildModelSelectGroups(modelSelectSource, modelSelectExisting), [modelSelectSource, modelSelectExisting]);
const activeModelSelectModels = useMemo(() => {
const keyword = modelSelectKeyword.trim().toLowerCase();
return modelSelectGroups[modelSelectTab].filter((model) => model.toLowerCase().includes(keyword));
}, [modelSelectGroups, modelSelectKeyword, modelSelectTab]);
const activeSelectedCount = activeModelSelectModels.filter((model) => modelSelectSelected.includes(model)).length;
const loadSettings = async () => {
if (!token) return;
@@ -196,16 +210,73 @@ export default function AdminSettingsPage() {
message.warning("请先填写 API Key");
return;
}
setIsFetchingChannelModels(true);
try {
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
channelForm.setFieldValue("models", channelModels);
const current = isModelSelectorOpen ? uniqueModels(modelSelectSelected) : uniqueModels(channelForm.getFieldValue("models") || []);
rememberModels(channelModels);
message.success(`已获取 ${channelModels.length} 个模型`);
setModelSelectExisting(current);
setModelSelectSource(uniqueModels(channelModels));
setModelSelectSelected(uniqueModels([...current, ...channelModels]));
setModelSelectKeyword("");
setModelSelectNewModel("");
setModelSelectTab("new");
setIsModelSelectorOpen(true);
message.success(`已获取 ${channelModels.length} 个模型,请选择后确认`);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setIsFetchingChannelModels(false);
}
};
const openChannelModelSelector = (sourceModels?: string[]) => {
const current = uniqueModels(channelForm.getFieldValue("models") || []);
const source = uniqueModels(sourceModels !== undefined ? sourceModels : [...knownModels, ...current]);
setModelSelectExisting(current);
setModelSelectSource(source);
setModelSelectSelected(sourceModels ? uniqueModels([...current, ...source]) : current);
setModelSelectKeyword("");
setModelSelectNewModel("");
setModelSelectTab(sourceModels ? "new" : "current");
setIsModelSelectorOpen(true);
};
const closeChannelModelSelector = () => {
setIsModelSelectorOpen(false);
setModelSelectKeyword("");
setModelSelectNewModel("");
};
const confirmChannelModelSelector = () => {
const models = uniqueModels(modelSelectSelected);
channelForm.setFieldValue("models", models);
rememberModels(models);
closeChannelModelSelector();
};
const toggleSelectedModel = (model: string, checked: boolean) => {
setModelSelectSelected((current) => (checked ? uniqueModels([...current, model]) : current.filter((item) => item !== model)));
};
const selectActiveModels = () => {
setModelSelectSelected((current) => uniqueModels([...current, ...activeModelSelectModels]));
};
const clearActiveModels = () => {
const active = new Set(activeModelSelectModels);
setModelSelectSelected((current) => current.filter((model) => !active.has(model)));
};
const addModelInSelector = () => {
const model = modelSelectNewModel.trim();
if (!model) return;
setModelSelectExisting((current) => uniqueModels([...current, model]));
setModelSelectSelected((current) => uniqueModels([...current, model]));
setModelSelectNewModel("");
setModelSelectTab("current");
};
function rememberModels(models: string[]) {
setKnownModels((current) => uniqueModels([...current, ...models]));
}
@@ -263,8 +334,10 @@ export default function AdminSettingsPage() {
async function persistChannels(nextChannels: AdminModelChannel[]) {
if (!token) return;
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
const nextChannelModels = collectChannelModels(nextChannels);
const nextSettings = normalizeSettings({
...values,
public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: filterModels(values.public.modelChannel.availableModels, nextChannelModels) } },
private: { ...values.private, channels: nextChannels },
});
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
@@ -337,8 +410,8 @@ export default function AdminSettingsPage() {
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Row gutter={16}>
<Col span={24}>
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先在私有配置里配置渠道)">
<Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} />
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先在私有配置里配置渠道)" extra="可选项来自已启用渠道中选择的模型,最终开放哪些模型由这里勾选决定">
<Select mode="multiple" placeholder="请选择系统可用模型" options={channelModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col xs={24} md={6}>
@@ -579,8 +652,8 @@ export default function AdminSettingsPage() {
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}>
<Input.Password />
<Form.Item name="apiKey" label="API Key" rules={editingChannelIndex === null ? [{ required: true, message: "请输入 API Key" }] : []}>
<Input.Password placeholder={editingChannelIndex === null ? "" : "留空则沿用已保存的 API Key"} />
</Form.Item>
</Col>
<Col span={24}>
@@ -589,9 +662,7 @@ export default function AdminSettingsPage() {
<Form.Item name="models" noStyle>
<Select mode="tags" maxTagCount="responsive" tokenSeparators={[",", "\n"]} options={knownModels.map((model) => ({ label: model, value: model }))} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>
</Button>
<Button onClick={() => openChannelModelSelector()}></Button>
</Space.Compact>
</Form.Item>
</Col>
@@ -603,6 +674,77 @@ export default function AdminSettingsPage() {
</Row>
</Form>
</Drawer>
<Modal
title={
<Space size={12}>
<Typography.Text type="secondary">
{modelSelectSelected.length} / {uniqueModels([...modelSelectSource, ...modelSelectExisting]).length}
</Typography.Text>
</Space>
}
open={isModelSelectorOpen}
width={960}
onCancel={closeChannelModelSelector}
footer={
<Space>
<Button onClick={closeChannelModelSelector}></Button>
<Button type="primary" onClick={confirmChannelModelSelector}>
</Button>
</Space>
}
destroyOnHidden
>
<Flex vertical gap={14}>
<Flex gap={12} wrap>
<Input.Search placeholder="搜索模型" allowClear value={modelSelectKeyword} onChange={(event) => setModelSelectKeyword(event.target.value)} style={{ flex: "1 1 260px" }} />
<Space.Compact style={{ flex: "1 1 320px" }}>
<Input value={modelSelectNewModel} placeholder="输入模型名称" onChange={(event) => setModelSelectNewModel(event.target.value)} onPressEnter={addModelInSelector} />
<Button onClick={addModelInSelector}></Button>
<Button icon={<ReloadOutlined />} loading={isFetchingChannelModels} onClick={() => void fetchChannelModelList()}>
</Button>
</Space.Compact>
</Flex>
<Tabs
activeKey={modelSelectTab}
onChange={(key) => setModelSelectTab(key as ModelSelectTabKey)}
items={[
{ key: "new", label: `新获取的模型 (${modelSelectGroups.new.length})` },
{ key: "current", label: `已有的模型 (${modelSelectGroups.current.length})` },
]}
/>
<Flex justify="space-between" align="center" gap={12} wrap>
<Typography.Text type="secondary">
{activeSelectedCount} / {activeModelSelectModels.length}
</Typography.Text>
<Space size={8}>
<Button size="small" disabled={!activeModelSelectModels.length || activeSelectedCount === activeModelSelectModels.length} onClick={selectActiveModels}>
</Button>
<Button size="small" disabled={!activeSelectedCount} onClick={clearActiveModels}>
</Button>
</Space>
</Flex>
<div style={{ maxHeight: 420, overflowY: "auto", borderTop: "1px solid var(--ant-color-border-secondary)", paddingTop: 12 }}>
{activeModelSelectModels.length ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", columnGap: 24, rowGap: 12 }}>
{activeModelSelectModels.map((model) => (
<Checkbox key={model} checked={modelSelectSelected.includes(model)} onChange={(event) => toggleSelectedModel(model, event.target.checked)}>
<Typography.Text style={{ wordBreak: "break-all" }}>{model}</Typography.Text>
</Checkbox>
))}
</div>
) : (
<div style={{ padding: "48px 0", textAlign: "center" }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</div>
</Flex>
</Modal>
<Modal
title={
<Space>
@@ -770,10 +912,25 @@ function collectKnownModels(settings: AdminSettings) {
]);
}
function buildModelSelectGroups(sourceModels: string[], existingModels: string[]): Record<ModelSelectTabKey, string[]> {
const source = uniqueModels(sourceModels);
const existing = uniqueModels(existingModels);
const existingSet = new Set(existing);
return {
new: source.filter((model) => !existingSet.has(model)),
current: existing,
};
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.filter(Boolean)));
}
function filterModels(models: string[], options: string[]) {
const optionSet = new Set(options);
return uniqueModels(models).filter((model) => optionSet.has(model));
}
function modelSummary(models: string[]) {
if (!models.length) return "未配置模型";
const preview = models.slice(0, 3).join(", ");
@@ -809,6 +966,7 @@ async function collectSettings(form: any, editorMode: Record<SettingsTabKey, Edi
}
values.private = privateSetting;
}
values.public.modelChannel.availableModels = filterModels(values.public.modelChannel.availableModels, collectChannelModels(values.private.channels));
return normalizeSettings(values);
}