fe3294ed60
- 新增UserAuth中间件用于验证用户登录状态和权限 - 移除Linux.do登录中的redirectUri和minimumTrustLevel配置项 - 修改Linux.do登录流程以支持动态构建回调URL - 将API v1接口组迁移至需要用户认证的路由 - 在远程AI服务调用中添加用户令牌认证 - 修复被封禁用户的认证检查逻辑 - 更新登录重定向函数以处理请求来源 - 添加RequestOrigin函数解析转发的主机和协议头
740 lines
38 KiB
TypeScript
740 lines
38 KiB
TypeScript
"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 格式不正确";
|
||
}
|
||
}
|