Files
infinite-canvas/web/src/app/(admin)/admin/settings/page.tsx
T
HouYunFei fe3294ed60 feat(auth): 添加用户认证中间件并重构Linux.do登录流程
- 新增UserAuth中间件用于验证用户登录状态和权限
- 移除Linux.do登录中的redirectUri和minimumTrustLevel配置项
- 修改Linux.do登录流程以支持动态构建回调URL
- 将API v1接口组迁移至需要用户认证的路由
- 在远程AI服务调用中添加用户令牌认证
- 修复被封禁用户的认证检查逻辑
- 更新登录重定向函数以处理请求来源
- 添加RequestOrigin函数解析转发的主机和协议头
2026-05-25 12:05:02 +08:00

740 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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 dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { EditorView } from "@uiw/react-codemirror";
import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminSettings } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false });
const jsonEditorTheme = EditorView.theme({
"&": { backgroundColor: "var(--ant-color-bg-container)", color: "var(--ant-color-text)" },
".cm-content": { caretColor: "var(--ant-color-text)", padding: "12px 0" },
".cm-line": { padding: "0 18px" },
".cm-gutters": { backgroundColor: "var(--ant-color-fill-quaternary)", borderRight: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" },
".cm-activeLine": { backgroundColor: "var(--ant-color-fill-quaternary)" },
".cm-activeLineGutter": { backgroundColor: "var(--ant-color-fill-quaternary)", color: "var(--ant-color-text)" },
".cm-cursor": { borderLeftColor: "var(--ant-color-text)" },
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: "var(--ant-control-item-bg-active)" },
".cm-foldPlaceholder": { backgroundColor: "var(--ant-color-fill-quaternary)", border: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" },
"&.cm-focused": { outline: "none" },
});
const emptySettings: AdminSettings = {
public: {
modelChannel: {
availableModels: [],
defaultModel: "",
defaultImageModel: "",
defaultVideoModel: "",
defaultTextModel: "",
systemPrompt: "",
allowCustomChannel: true,
},
auth: { linuxDo: { enabled: false } },
},
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
};
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
type SettingsTabKey = "public" | "private";
type EditorMode = "visual" | "json";
export default function AdminSettingsPage() {
const token = useUserStore((state) => state.token);
const { message } = App.useApp();
const [form] = Form.useForm<AdminSettings>();
const [activeTab, setActiveTab] = useState<SettingsTabKey>("public");
const [editorMode, setEditorMode] = useState<Record<SettingsTabKey, EditorMode>>({ public: "visual", private: "visual" });
const [jsonText, setJsonText] = useState<Record<SettingsTabKey, string>>({ public: "", private: "" });
const [channels, setChannels] = useState<AdminModelChannel[]>([]);
const [channelForm] = Form.useForm<AdminModelChannel>();
const [editingChannelIndex, setEditingChannelIndex] = useState<number | null>(null);
const [isChannelDrawerOpen, setIsChannelDrawerOpen] = useState(false);
const [testChannelIndex, setTestChannelIndex] = useState<number | null>(null);
const [testKeyword, setTestKeyword] = useState("");
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
const [testingModels, setTestingModels] = useState<string[]>([]);
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
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([...publicModels, ...channelModels]), [publicModels, channelModels]);
const activeMode = editorMode[activeTab];
const activeJsonText = jsonText[activeTab];
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
const loadSettings = async () => {
if (!token) return;
setIsLoading(true);
try {
const data = normalizeSettings(await fetchAdminSettings(token));
form.setFieldsValue(data);
setChannels(data.private.channels);
setJsonText({
public: JSON.stringify(data.public, null, 2),
private: JSON.stringify(data.private, null, 2),
});
} catch (error) {
message.error(error instanceof Error ? error.message : "读取设置失败");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadSettings();
}, [token]);
const changeTab = (nextTab: SettingsTabKey) => {
setActiveTab(nextTab);
};
const saveSettings = async () => {
if (!token) return;
const values = await collectSettings(form, editorMode, jsonText, message);
if (!values) {
return;
}
setIsSaving(true);
try {
const saved = normalizeSettings(await saveAdminSettings(token, values));
const merged = mergeChannelApiKeys(values.private.channels, saved);
form.setFieldsValue(merged);
setChannels(merged.private.channels);
setJsonText({
public: JSON.stringify(merged.public, null, 2),
private: JSON.stringify(merged.private, null, 2),
});
message.success("已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
};
const toggleMode = (tab: SettingsTabKey, nextMode: EditorMode) => {
if (nextMode === "json") {
setJsonText((current) => ({
...current,
[tab]: JSON.stringify(tab === "public" ? normalizePublicSetting(form.getFieldValue(["public"]) as Partial<AdminSettings["public"]>) : normalizePrivateSetting(form.getFieldValue(["private"]) as Partial<AdminSettings["private"]>), null, 2),
}));
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
return;
}
const parsed = parseTabJson(tab, jsonText[tab]);
if (!parsed) {
message.error("JSON 格式不正确");
return;
}
form.setFieldsValue({ [tab]: parsed } as Partial<AdminSettings>);
if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels);
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
};
const formatJson = (tab: SettingsTabKey) => {
const parsed = parseTabJson(tab, jsonText[tab]);
if (!parsed) {
message.error("JSON 格式不正确");
return;
}
setJsonText((current) => ({
...current,
[tab]: JSON.stringify(parsed, null, 2),
}));
};
const openChannelDrawer = (index: number | null) => {
setEditingChannelIndex(index);
setIsChannelDrawerOpen(true);
channelForm.setFieldsValue(index === null ? emptyChannel : normalizeChannel(channels[index]));
};
const closeChannelDrawer = () => {
setIsChannelDrawerOpen(false);
setEditingChannelIndex(null);
channelForm.resetFields();
};
const saveChannel = async () => {
const channel = normalizeChannel(await channelForm.validateFields());
const nextChannels = [...channels];
if (editingChannelIndex === null) nextChannels.push(channel);
else nextChannels[editingChannelIndex] = channel;
await persistChannels(nextChannels);
closeChannelDrawer();
};
const fetchChannelModelList = async () => {
if (!token) return;
const channel = channelForm.getFieldsValue();
if (!channel?.baseUrl) {
message.warning("请先填写接口地址");
return;
}
if (editingChannelIndex === null && !channel?.apiKey) {
message.warning("请先填写 API Key");
return;
}
try {
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
channelForm.setFieldValue("models", channelModels);
message.success(`已获取 ${channelModels.length} 个模型`);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
}
};
const openTestDialog = (index: number) => {
const channel = normalizeChannel(channels[index]);
if (!channel.baseUrl || channel.models.length === 0) {
message.warning("请先填写接口地址和至少一个模型");
return;
}
setTestChannelIndex(index);
setTestKeyword("");
setSelectedTestModels([]);
setTestingModels([]);
setTestResults({});
};
const closeTestDialog = () => {
setTestChannelIndex(null);
setTestKeyword("");
setSelectedTestModels([]);
setTestingModels([]);
setTestResults({});
};
const testModelOnline = async (model: string) => {
if (testChannelIndex === null) return;
if (!token) return;
const channel = normalizeChannel(channels[testChannelIndex]);
setTestingModels((current) => [...current, model]);
try {
const startedAt = performance.now();
const result = await testChannelModel(token, { index: testChannelIndex, channel, model });
setTestResults((current) => ({ ...current, [model]: { status: "success", duration: `${((performance.now() - startedAt) / 1000).toFixed(2)}s`, message: result } }));
} catch (error) {
setTestResults((current) => ({ ...current, [model]: { status: "error", message: error instanceof Error ? error.message : "测试失败" } }));
} finally {
setTestingModels((current) => current.filter((item) => item !== model));
}
};
const batchTestModels = async () => {
for (const model of selectedTestModels) {
await testModelOnline(model);
}
};
const testChannel = testChannelIndex === null ? null : normalizeChannel(channels[testChannelIndex]);
const testModels = (testChannel?.models || []).filter((model) => model.toLowerCase().includes(testKeyword.trim().toLowerCase()));
async function persistChannels(nextChannels: AdminModelChannel[]) {
if (!token) return;
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
const nextSettings = normalizeSettings({
...values,
private: { ...values.private, channels: nextChannels },
});
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
const merged = mergeChannelApiKeys(nextChannels, saved);
setChannels(merged.private.channels);
form.setFieldsValue(merged);
setJsonText({
public: JSON.stringify(merged.public, null, 2),
private: JSON.stringify(merged.private, null, 2),
});
message.success("已保存");
}
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Flex justify="space-between" align="center" gap={16} wrap>
<Tabs
activeKey={activeTab}
onChange={(key) => changeTab(key as SettingsTabKey)}
items={[
{ key: "public", label: "公开配置(对外暴露)" },
{ key: "private", label: "私有配置(不会对外暴露)" },
]}
/>
<Space>
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>
</Button>
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>
</Button>
</Space>
</Flex>
</Card>
<Card variant="borderless">
<Flex justify="space-between" align="center" gap={16} wrap style={{ marginBottom: 16 }}>
<Segmented
value={activeMode}
onChange={(value) => toggleMode(activeTab, value as EditorMode)}
options={[
{ label: "可视化编辑", value: "visual" },
{ label: "手动编辑 JSON", value: "json" },
]}
/>
{activeMode === "json" ? (
<Space>
{jsonError ? (
<Tag color="error">{jsonError}</Tag>
) : (
<Tag color="success" icon={<CheckCircleOutlined />}>
JSON
</Tag>
)}
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>
</Button>
</Space>
) : (
<Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text>
)}
</Flex>
{activeTab === "public" ? (
activeMode === "visual" ? (
<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>
</Col>
<Col xs={24} md={6}>
<Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型">
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型">
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name={["public", "modelChannel", "defaultVideoModel"]} label="默认视频模型">
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型">
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词">
<Input.TextArea rows={4} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
</Form>
) : (
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
<CodeMirror
value={activeJsonText}
height="520px"
extensions={[json(), jsonEditorTheme]}
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
theme="none"
onChange={(value) => setJsonText((current) => ({ ...current, public: value }))}
style={{ fontSize: 13 }}
/>
</div>
)
) : activeMode === "visual" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Flex vertical gap={12}>
<Card
size="small"
title={
<Space>
<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />
Linux.do
</Space>
}
>
<Flex vertical gap={14}>
<Typography.Text type="secondary">
/api/auth/linux-do/callback Linux.do
<Typography.Link href="https://connect.linux.do" target="_blank" rel="noreferrer">
LinuxDO OAuth App
</Typography.Link>
</Typography.Text>
<Row gutter={16}>
<Col xs={24} md={6}>
<Form.Item name={["public", "auth", "linuxDo", "enabled"]} label="开启 Linux.do 登录" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={9}>
<Form.Item name={["private", "auth", "linuxDo", "clientId"]} label="Linux.do Client ID">
<Input placeholder="输入 Linux.do OAuth App 的 ID" />
</Form.Item>
</Col>
<Col xs={24} md={9}>
<Form.Item name={["private", "auth", "linuxDo", "clientSecret"]} label="Linux.do Client Secret">
<Input.Password placeholder="留空则沿用已保存的密钥" />
</Form.Item>
</Col>
</Row>
</Flex>
</Card>
<Card size="small" title="提示词定时同步">
<Row gutter={16} align="middle">
<Col xs={24} md={8}>
<Form.Item name={["private", "promptSync", "enabled"]} label="开启定时同步" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={16}>
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每 5 分钟同步内置 GitHub 远程提示词源">
<Input placeholder="*/5 * * * *" />
</Form.Item>
</Col>
</Row>
</Card>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>
</Button>
<Table
rowKey="_rowKey"
pagination={false}
dataSource={channelTableData}
columns={[
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
{
title: "模型",
dataIndex: "models",
render: (value: string[]) => (
<Typography.Text ellipsis style={{ maxWidth: 360 }}>
{modelSummary(value || [])}
</Typography.Text>
),
},
{ title: "权重", dataIndex: "weight", width: 88 },
{
title: "操作",
key: "actions",
width: 220,
align: "right",
render: (_, item) => (
<Space size={4}>
<Button size="small" onClick={() => openTestDialog(item._index)}>
</Button>
<Button size="small" onClick={() => openChannelDrawer(item._index)}>
</Button>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => {
const nextChannels = [...channels];
nextChannels.splice(item._index, 1);
void persistChannels(nextChannels);
}}
/>
</Space>
),
},
]}
/>
</Flex>
</Form>
) : (
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
<CodeMirror
value={activeJsonText}
height="520px"
extensions={[json(), jsonEditorTheme]}
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
theme="none"
onChange={(value) => setJsonText((current) => ({ ...current, private: value }))}
style={{ fontSize: 13 }}
/>
</div>
)}
</Card>
<Drawer
title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"}
open={isChannelDrawerOpen}
size={560}
onClose={closeChannelDrawer}
extra={
<Space>
<Button onClick={closeChannelDrawer}></Button>
<Button type="primary" onClick={() => void saveChannel()}>
</Button>
</Space>
}
destroyOnHidden
>
<Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="protocol" label="协议">
<Select options={[{ label: "OpenAI", value: "openai" }]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="weight" label="权重">
<InputNumber min={1} step={1} className="!w-full" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}>
<Input />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}>
<Input.Password />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="渠道可用模型">
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="models" noStyle>
<Select mode="tags" tokenSeparators={[",", "\n"]} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>
</Button>
</Space.Compact>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Col>
</Row>
</Form>
</Drawer>
<Modal
title={
<Space>
{testChannel?.name || "渠道"} <Typography.Text type="secondary"> {testChannel?.models.length || 0} </Typography.Text>
</Space>
}
open={testChannelIndex !== null}
width={920}
onCancel={closeTestDialog}
footer={
<Space>
<Button onClick={closeTestDialog}></Button>
<Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>
{selectedTestModels.length}
</Button>
</Space>
}
destroyOnHidden
>
<Flex vertical gap={12}>
<Typography.Text type="secondary"> hi</Typography.Text>
<Input.Search placeholder="搜索模型..." allowClear value={testKeyword} onChange={(event) => setTestKeyword(event.target.value)} />
<Table
rowKey="model"
pagination={false}
scroll={{ y: 420 }}
dataSource={testModels.map((model) => ({ model }))}
rowSelection={{
selectedRowKeys: selectedTestModels,
onChange: (keys) => setSelectedTestModels(keys.map(String)),
}}
columns={[
{ title: "模型名称", dataIndex: "model", render: (value) => <Typography.Text strong>{value}</Typography.Text> },
{
title: "状态",
dataIndex: "model",
width: 260,
render: (value) => {
if (testingModels.includes(value)) return <Tag icon={<LoadingOutlined className="animate-spin" />}></Tag>;
const result = testResults[value];
if (!result) return <Tag></Tag>;
return result.status === "success" ? (
<Space size={6} wrap>
<Tag color="success"></Tag>
<Typography.Text type="secondary">: {result.duration}</Typography.Text>
</Space>
) : (
<Typography.Text type="danger">{result.message}</Typography.Text>
);
},
},
{
title: "操作",
key: "actions",
width: 120,
align: "right",
render: (_, item) => (
<Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>
</Button>
),
},
]}
/>
</Flex>
</Modal>
</Flex>
</main>
);
}
function normalizeSettings(settings: Partial<AdminSettings> = {}): AdminSettings {
const privateSetting = normalizePrivateSetting(settings.private);
return {
public: {
...normalizePublicSetting(settings.public),
},
private: privateSetting,
};
}
function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}): AdminSettings["public"] {
return {
...emptySettings.public,
modelChannel: {
...emptySettings.public.modelChannel,
...(setting.modelChannel || {}),
availableModels: setting.modelChannel?.availableModels || [],
},
auth: {
linuxDo: {
enabled: setting.auth?.linuxDo?.enabled === true,
},
},
};
}
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
return {
channels: (setting.channels || []).map(normalizeChannel),
promptSync: {
enabled: setting.promptSync?.enabled !== false,
cron: setting.promptSync?.cron || "*/5 * * * *",
},
auth: {
linuxDo: {
clientId: setting.auth?.linuxDo?.clientId || "",
clientSecret: setting.auth?.linuxDo?.clientSecret || "",
},
},
};
}
function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChannel {
return {
protocol: "openai",
name: item.name || "",
baseUrl: item.baseUrl || "",
apiKey: item.apiKey || "",
models: item.models || [],
weight: Math.max(1, Number(item.weight) || 1),
enabled: item.enabled !== false,
remark: item.remark || "",
};
}
function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminSettings): AdminSettings {
const channels = saved.private.channels.map((item, index) => ({
...item,
apiKey: currentChannels[index]?.apiKey || item.apiKey,
}));
return {
public: saved.public,
private: { ...saved.private, channels },
};
}
function collectChannelModels(channels: AdminModelChannel[]) {
return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || []));
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.filter(Boolean)));
}
function modelSummary(models: string[]) {
if (!models.length) return "未配置模型";
const preview = models.slice(0, 3).join(", ");
return models.length > 3 ? `${models.length} 个模型:${preview}...` : preview;
}
function parseTabJson(tab: "public", value: string): AdminSettings["public"] | null;
function parseTabJson(tab: "private", value: string): AdminSettings["private"] | null;
function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null;
function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null {
try {
return tab === "public" ? normalizePublicSetting(JSON.parse(value) as Partial<AdminSettings["public"]>) : normalizePrivateSetting(JSON.parse(value) as Partial<AdminSettings["private"]>);
} catch {
return null;
}
}
async function collectSettings(form: any, editorMode: Record<SettingsTabKey, EditorMode>, jsonText: Record<SettingsTabKey, string>, message: { error: (value: string) => void }) {
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
if (editorMode.public === "json") {
const publicSetting = parseTabJson("public", jsonText.public);
if (!publicSetting) {
message.error("公开配置 JSON 格式不正确");
return null;
}
values.public = publicSetting;
}
if (editorMode.private === "json") {
const privateSetting = parseTabJson("private", jsonText.private);
if (!privateSetting) {
message.error("私有配置 JSON 格式不正确");
return null;
}
values.private = privateSetting;
}
return normalizeSettings(values);
}
function getJsonError(value: string) {
try {
JSON.parse(value);
return "";
} catch (error) {
return error instanceof Error ? error.message : "JSON 格式不正确";
}
}