feat(admin): 新增管理后台模型渠道配置功能
- 实现了管理后台模型渠道的增删改查功能 - 将前端直接调用API改为通过后端接口代理获取模型列表 - 添加了渠道测试连接和模型可用性检测功能 - 实现了API Key安全存储机制,前端不再明文显示 - 更新了管理后台设置页面的渠道配置界面 - 添加了表格选中行背景色主题配置支持 - 重构了错误处理机制,统一返回安全错误信息 - 整理了后端模型代理路径为OpenAI风格的/api/v1/*
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, 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 { Alert, 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";
|
||||
@@ -102,11 +102,12 @@ export default function AdminSettingsPage() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const saved = normalizeSettings(await saveAdminSettings(token, values));
|
||||
form.setFieldsValue(saved);
|
||||
setChannels(saved.private.channels);
|
||||
const merged = mergeChannelApiKeys(values.private.channels, saved);
|
||||
form.setFieldsValue(merged);
|
||||
setChannels(merged.private.channels);
|
||||
setJsonText({
|
||||
public: JSON.stringify(saved.public, null, 2),
|
||||
private: JSON.stringify(saved.private, null, 2),
|
||||
public: JSON.stringify(merged.public, null, 2),
|
||||
private: JSON.stringify(merged.private, null, 2),
|
||||
});
|
||||
message.success("已保存");
|
||||
} catch (error) {
|
||||
@@ -164,19 +165,23 @@ export default function AdminSettingsPage() {
|
||||
const nextChannels = [...channels];
|
||||
if (editingChannelIndex === null) nextChannels.push(channel);
|
||||
else nextChannels[editingChannelIndex] = channel;
|
||||
setChannels(nextChannels);
|
||||
form.setFieldValue(["private", "channels"], nextChannels);
|
||||
await persistChannels(nextChannels);
|
||||
closeChannelDrawer();
|
||||
};
|
||||
|
||||
const fetchChannelModelList = async () => {
|
||||
if (!token) return;
|
||||
const channel = channelForm.getFieldsValue();
|
||||
if (!channel?.baseUrl || !channel?.apiKey) {
|
||||
message.warning("请先填写接口地址和 API Key");
|
||||
if (!channel?.baseUrl) {
|
||||
message.warning("请先填写接口地址");
|
||||
return;
|
||||
}
|
||||
if (editingChannelIndex === null && !channel?.apiKey) {
|
||||
message.warning("请先填写 API Key");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const channelModels = await fetchChannelModels(channel);
|
||||
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
|
||||
channelForm.setFieldValue("models", channelModels);
|
||||
message.success(`已获取 ${channelModels.length} 个模型`);
|
||||
} catch (error) {
|
||||
@@ -186,8 +191,8 @@ export default function AdminSettingsPage() {
|
||||
|
||||
const openTestDialog = (index: number) => {
|
||||
const channel = normalizeChannel(channels[index]);
|
||||
if (!channel.baseUrl || !channel.apiKey || channel.models.length === 0) {
|
||||
message.warning("请先填写接口地址、API Key 和至少一个模型");
|
||||
if (!channel.baseUrl || channel.models.length === 0) {
|
||||
message.warning("请先填写接口地址和至少一个模型");
|
||||
return;
|
||||
}
|
||||
setTestChannelIndex(index);
|
||||
@@ -207,11 +212,12 @@ export default function AdminSettingsPage() {
|
||||
|
||||
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(channel, model);
|
||||
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 : "测试失败" } }));
|
||||
@@ -229,6 +235,24 @@ export default function AdminSettingsPage() {
|
||||
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}>
|
||||
@@ -322,8 +346,7 @@ export default function AdminSettingsPage() {
|
||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => {
|
||||
const nextChannels = [...channels];
|
||||
nextChannels.splice(index, 1);
|
||||
setChannels(nextChannels);
|
||||
form.setFieldValue(["private", "channels"], nextChannels);
|
||||
void persistChannels(nextChannels);
|
||||
}} />
|
||||
</Space>
|
||||
),
|
||||
@@ -394,7 +417,7 @@ export default function AdminSettingsPage() {
|
||||
dataIndex: "model",
|
||||
width: 260,
|
||||
render: (value) => {
|
||||
if (testingModels.includes(value)) return <Tag color="processing">测试中</Tag>;
|
||||
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" ? (
|
||||
@@ -463,6 +486,17 @@ function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChan
|
||||
};
|
||||
}
|
||||
|
||||
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: { channels },
|
||||
};
|
||||
}
|
||||
|
||||
function collectChannelModels(channels: AdminModelChannel[]) {
|
||||
return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || []));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ const neutral = {
|
||||
selectActiveBg: "#f5f5f5",
|
||||
selectSelectedBg: "#f0f0f0",
|
||||
selectText: "#171717",
|
||||
tableSelectedBg: "rgba(17, 17, 17, 0.05)",
|
||||
tableSelectedHoverBg: "rgba(17, 17, 17, 0.08)",
|
||||
},
|
||||
dark: {
|
||||
primary: "#fafafa",
|
||||
@@ -22,6 +24,8 @@ const neutral = {
|
||||
selectActiveBg: "#262626",
|
||||
selectSelectedBg: "#333333",
|
||||
selectText: "#fafafa",
|
||||
tableSelectedBg: "rgba(255, 255, 255, 0.08)",
|
||||
tableSelectedHoverBg: "rgba(255, 255, 255, 0.12)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,6 +69,10 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
optionSelectedBg: color.selectSelectedBg,
|
||||
optionSelectedColor: color.selectText,
|
||||
},
|
||||
Table: {
|
||||
rowSelectedBg: color.tableSelectedBg,
|
||||
rowSelectedHoverBg: color.tableSelectedHoverBg,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
|
||||
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
|
||||
import { buildApiUrl } from "@/stores/use-config-store";
|
||||
import axios from "axios";
|
||||
|
||||
export type AdminPromptCategory = {
|
||||
category: string;
|
||||
@@ -125,36 +123,16 @@ export async function saveAdminSettings(token: string, settings: AdminSettings)
|
||||
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
|
||||
}
|
||||
|
||||
export async function fetchChannelModels(channel: Pick<AdminModelChannel, "baseUrl" | "apiKey">) {
|
||||
try {
|
||||
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(channel.baseUrl, "/models"), {
|
||||
headers: { Authorization: `Bearer ${channel.apiKey}` },
|
||||
});
|
||||
return (response.data.data || [])
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
} catch (error) {
|
||||
throw new Error(readChannelError(error, "读取模型失败"));
|
||||
}
|
||||
export type AdminChannelActionRequest = {
|
||||
index?: number;
|
||||
channel: AdminModelChannel;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) {
|
||||
return apiPost<string[]>("/api/admin/settings/channel-models", payload, token);
|
||||
}
|
||||
|
||||
export async function testChannelModel(channel: Pick<AdminModelChannel, "baseUrl" | "apiKey">, model: string) {
|
||||
try {
|
||||
const response = await axios.post<{ choices?: Array<{ message?: { content?: string } }>; error?: { message?: string } }>(
|
||||
buildApiUrl(channel.baseUrl, "/chat/completions"),
|
||||
{ model, messages: [{ role: "user", content: "hi" }] },
|
||||
{ headers: { Authorization: `Bearer ${channel.apiKey}`, "Content-Type": "application/json" } },
|
||||
);
|
||||
return response.data.choices?.[0]?.message?.content || "ok";
|
||||
} catch (error) {
|
||||
throw new Error(readChannelError(error, "测试失败"));
|
||||
}
|
||||
}
|
||||
|
||||
function readChannelError(error: unknown, fallback: string) {
|
||||
if (axios.isAxiosError<{ error?: { message?: string } }>(error)) {
|
||||
return error.response?.data?.error?.message || (error.response?.status ? `${fallback}:${error.response.status}` : fallback);
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
export async function testChannelModel(token: string, payload: AdminChannelActionRequest) {
|
||||
return apiPost<string>("/api/admin/settings/channel-test", payload, token);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user