feat(admin): 优化管理后台渠道模型选择功能
- 在管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按"新获取、已有"分组选择 - 弹窗内支持手动增加模型或拉取模型列表后再写回表单 - 编辑渠道时API Key留空不再触发必填校验,表示沿用已保存的密钥 - 新增渠道仍要求填写API Key以确保安全性 - 公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成 - 最终开放哪些模型仍由公开配置里手动勾选决定 - 添加了Checkbox组件用于多选模型功能 - 实现了模型选择器的状态管理和数据过滤逻辑
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
||||||
|
- 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。
|
||||||
|
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
|
||||||
|
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
|
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
|
||||||
import { json } from "@codemirror/lang-json";
|
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 dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { EditorView } from "@uiw/react-codemirror";
|
import { EditorView } from "@uiw/react-codemirror";
|
||||||
@@ -44,6 +44,7 @@ const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl:
|
|||||||
|
|
||||||
type SettingsTabKey = "public" | "private";
|
type SettingsTabKey = "public" | "private";
|
||||||
type EditorMode = "visual" | "json";
|
type EditorMode = "visual" | "json";
|
||||||
|
type ModelSelectTabKey = "new" | "current";
|
||||||
|
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
const token = useUserStore((state) => state.token);
|
const token = useUserStore((state) => state.token);
|
||||||
@@ -61,6 +62,14 @@ export default function AdminSettingsPage() {
|
|||||||
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
|
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
|
||||||
const [testingModels, setTestingModels] = useState<string[]>([]);
|
const [testingModels, setTestingModels] = useState<string[]>([]);
|
||||||
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: 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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [modelCosts, setModelCosts] = useState<AdminModelCost[]>([]);
|
const [modelCosts, setModelCosts] = useState<AdminModelCost[]>([]);
|
||||||
@@ -68,10 +77,15 @@ export default function AdminSettingsPage() {
|
|||||||
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
||||||
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
||||||
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [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 activeMode = editorMode[activeTab];
|
||||||
const activeJsonText = jsonText[activeTab];
|
const activeJsonText = jsonText[activeTab];
|
||||||
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
|
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 () => {
|
const loadSettings = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -196,16 +210,73 @@ export default function AdminSettingsPage() {
|
|||||||
message.warning("请先填写 API Key");
|
message.warning("请先填写 API Key");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setIsFetchingChannelModels(true);
|
||||||
try {
|
try {
|
||||||
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
|
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);
|
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) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
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[]) {
|
function rememberModels(models: string[]) {
|
||||||
setKnownModels((current) => uniqueModels([...current, ...models]));
|
setKnownModels((current) => uniqueModels([...current, ...models]));
|
||||||
}
|
}
|
||||||
@@ -263,8 +334,10 @@ export default function AdminSettingsPage() {
|
|||||||
async function persistChannels(nextChannels: AdminModelChannel[]) {
|
async function persistChannels(nextChannels: AdminModelChannel[]) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
|
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
|
||||||
|
const nextChannelModels = collectChannelModels(nextChannels);
|
||||||
const nextSettings = normalizeSettings({
|
const nextSettings = normalizeSettings({
|
||||||
...values,
|
...values,
|
||||||
|
public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: filterModels(values.public.modelChannel.availableModels, nextChannelModels) } },
|
||||||
private: { ...values.private, channels: nextChannels },
|
private: { ...values.private, channels: nextChannels },
|
||||||
});
|
});
|
||||||
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
|
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
|
||||||
@@ -337,8 +410,8 @@ export default function AdminSettingsPage() {
|
|||||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先在私有配置里配置渠道)">
|
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先在私有配置里配置渠道)" extra="可选项来自已启用渠道中选择的模型,最终开放哪些模型由这里勾选决定">
|
||||||
<Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} />
|
<Select mode="multiple" placeholder="请选择系统可用模型" options={channelModels.map((item) => ({ label: item, value: item }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={6}>
|
<Col xs={24} md={6}>
|
||||||
@@ -579,8 +652,8 @@ export default function AdminSettingsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}>
|
<Form.Item name="apiKey" label="API Key" rules={editingChannelIndex === null ? [{ required: true, message: "请输入 API Key" }] : []}>
|
||||||
<Input.Password />
|
<Input.Password placeholder={editingChannelIndex === null ? "" : "留空则沿用已保存的 API Key"} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
@@ -589,9 +662,7 @@ export default function AdminSettingsPage() {
|
|||||||
<Form.Item name="models" noStyle>
|
<Form.Item name="models" noStyle>
|
||||||
<Select mode="tags" maxTagCount="responsive" tokenSeparators={[",", "\n"]} options={knownModels.map((model) => ({ label: model, value: model }))} />
|
<Select mode="tags" maxTagCount="responsive" tokenSeparators={[",", "\n"]} options={knownModels.map((model) => ({ label: model, value: model }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>
|
<Button onClick={() => openChannelModelSelector()}>选择模型</Button>
|
||||||
获取模型列表
|
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -603,6 +674,77 @@ export default function AdminSettingsPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</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
|
<Modal
|
||||||
title={
|
title={
|
||||||
<Space>
|
<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[]) {
|
function uniqueModels(models: string[]) {
|
||||||
return Array.from(new Set(models.filter(Boolean)));
|
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[]) {
|
function modelSummary(models: string[]) {
|
||||||
if (!models.length) return "未配置模型";
|
if (!models.length) return "未配置模型";
|
||||||
const preview = models.slice(0, 3).join(", ");
|
const preview = models.slice(0, 3).join(", ");
|
||||||
@@ -809,6 +966,7 @@ async function collectSettings(form: any, editorMode: Record<SettingsTabKey, Edi
|
|||||||
}
|
}
|
||||||
values.private = privateSetting;
|
values.private = privateSetting;
|
||||||
}
|
}
|
||||||
|
values.public.modelChannel.availableModels = filterModels(values.public.modelChannel.availableModels, collectChannelModels(values.private.channels));
|
||||||
return normalizeSettings(values);
|
return normalizeSettings(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user