feat(admin): 新增系统设置页面和配置管理功能

- 新增 settings 表用于存储 public/private 两行 JSON 配置
- 添加 AdminSettings 相关类型定义和 API 接口
- 实现管理后台系统设置页面,支持可视化和 JSON 编辑模式
- 集成 CodeMirror 编辑器用于 JSON 配置编辑
- 添加系统设置相关的路由和接口
- 更新依赖包并修改相关样式和布局文件
This commit is contained in:
HouYunFei
2026-05-20 17:53:07 +08:00
parent a78eef73a8
commit cf085770e7
17 changed files with 578 additions and 8 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined } from "@ant-design/icons";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons";
import { Button, Flex, Layout, Menu, Typography } from "antd";
import { LogOut } from "lucide-react";
import Link from "next/link";
@@ -21,8 +21,8 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
const logout = useUserStore((state) => state.clearSession);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const activeKey = pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
const pageTitle = pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
useEffect(() => {
@@ -58,6 +58,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
items={[
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: <Link href="/admin/prompts" style={{ color: "inherit" }}></Link> },
{ key: "/admin/assets", icon: <PictureOutlined />, label: <Link href="/admin/assets" style={{ color: "inherit" }}></Link> },
{ key: "/admin/settings", icon: <SettingOutlined />, label: <Link href="/admin/settings" style={{ color: "inherit" }}></Link> },
]}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12 }}>
+1 -1
View File
@@ -136,7 +136,7 @@ export default function AdminPromptsPage() {
) : null}
</Modal>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} maskClosable={!isSyncing} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}>
<Table
rowKey="category"
dataSource={categories.filter((item) => item.remote)}
+242
View File
@@ -0,0 +1,242 @@
"use client";
import { CheckCircleOutlined, CodeOutlined, DeleteOutlined, FormatPainterOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { json } from "@codemirror/lang-json";
import { tags } from "@lezer/highlight";
import { App, Button, Card, Col, Flex, Form, Input, Row, Segmented, Select, Space, Switch, Tag, Typography } from "antd";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { EditorView } from "@uiw/react-codemirror";
import { fetchAdminSettings, saveAdminSettings, 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(--card)", color: "var(--foreground)" },
".cm-content": { caretColor: "var(--foreground)", padding: "12px 0" },
".cm-line": { padding: "0 18px" },
".cm-gutters": { backgroundColor: "color-mix(in srgb, var(--card) 94%, var(--foreground))", borderRight: "1px solid var(--border)", color: "var(--muted-foreground)" },
".cm-activeLine": { backgroundColor: "color-mix(in srgb, var(--primary) 9%, transparent)" },
".cm-activeLineGutter": { backgroundColor: "color-mix(in srgb, var(--primary) 10%, transparent)", color: "var(--foreground)" },
".cm-cursor": { borderLeftColor: "var(--foreground)" },
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: "color-mix(in srgb, var(--primary) 25%, transparent)" },
".cm-foldPlaceholder": { backgroundColor: "var(--muted)", border: "1px solid var(--border)", color: "var(--muted-foreground)" },
"&.cm-focused": { outline: "none" },
}, { dark: true });
const jsonHighlightStyle = HighlightStyle.define([
{ tag: tags.propertyName, color: "#f2a7a7" },
{ tag: tags.string, color: "#d6c6a8" },
{ tag: tags.number, color: "#ddb27d" },
{ tag: tags.bool, color: "#c5b3ff" },
{ tag: tags.null, color: "#c5b3ff" },
{ tag: tags.punctuation, color: "rgba(250, 250, 249, 0.62)" },
{ tag: tags.squareBracket, color: "rgba(250, 250, 249, 0.62)" },
{ tag: tags.brace, color: "rgba(250, 250, 249, 0.62)" },
]);
const emptySettings: AdminSettings = {
public: {
availableModels: [],
defaultModel: "",
defaultImageModel: "",
defaultTextModel: "",
systemPrompt: "",
allowCustomModel: false,
},
private: { channels: [] },
};
export default function AdminSettingsPage() {
const token = useUserStore((state) => state.token);
const { message } = App.useApp();
const [form] = Form.useForm<AdminSettings>();
const [mode, setMode] = useState<"visual" | "json">("visual");
const [jsonText, setJsonText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const models = Form.useWatch(["public", "availableModels"], form) || [];
const jsonError = mode === "json" ? getJsonError(jsonText) : "";
const loadSettings = async () => {
if (!token) return;
setIsLoading(true);
try {
const data = normalizeSettings(await fetchAdminSettings(token));
form.setFieldsValue(data);
setJsonText(JSON.stringify(data, null, 2));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取设置失败");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadSettings();
}, [token]);
const changeMode = (nextMode: "visual" | "json") => {
if (nextMode === "json") {
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
setJsonText(JSON.stringify(values, null, 2));
} else {
const parsed = parseJsonSettings(jsonText);
if (!parsed) {
message.error("JSON 格式不正确");
return;
}
form.setFieldsValue(parsed);
}
setMode(nextMode);
};
const saveSettings = async () => {
if (!token) return;
const values = mode === "json" ? parseJsonSettings(jsonText) : normalizeSettings(await form.validateFields());
if (!values) {
message.error("JSON 格式不正确");
return;
}
setIsSaving(true);
try {
const saved = normalizeSettings(await saveAdminSettings(token, values));
form.setFieldsValue(saved);
setJsonText(JSON.stringify(saved, null, 2));
message.success("已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
};
const formatJson = () => {
const parsed = parseJsonSettings(jsonText);
if (!parsed) {
message.error("JSON 格式不正确");
return;
}
setJsonText(JSON.stringify(parsed, null, 2));
};
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Flex justify="space-between" align="center" gap={16} wrap>
<Space>
<Segmented value={mode} onChange={(value) => changeMode(value as "visual" | "json")} options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]} />
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}></Button>
</Space>
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}></Button>
</Flex>
</Card>
{mode === "visual" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Card title="公开配置" variant="borderless" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={24}><Form.Item name={["public", "availableModels"]} label="系统可用模型"><Select mode="tags" tokenSeparators={[",", "\n"]} options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={["public", "defaultModel"]} label="默认模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={["public", "defaultImageModel"]} label="默认图片模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={["public", "defaultTextModel"]} label="默认文本模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col span={24}><Form.Item name={["public", "systemPrompt"]} label="系统提示词"><Input.TextArea rows={4} /></Form.Item></Col>
<Col span={24}><Form.Item name={["public", "allowCustomModel"]} label="允许用户自定义模型" valuePropName="checked"><Switch /></Form.Item></Col>
</Row>
</Card>
<Card title="模型渠道" variant="borderless">
<Form.List name={["private", "channels"]}>
{(fields, { add, remove }) => (
<Flex vertical gap={12}>
{fields.map((field) => (
<div key={field.key} style={{ border: "1px solid var(--ant-color-border)", borderRadius: 6, padding: 16 }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
<Typography.Text strong>{`渠道 ${field.name + 1}`}</Typography.Text>
<Button danger type="text" icon={<DeleteOutlined />} onClick={() => remove(field.name)} />
</Flex>
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name={[field.name, "key"]} label="渠道标识"><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={[field.name, "name"]} label="渠道名称"><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={[field.name, "enabled"]} label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
<Col xs={24} md={12}><Form.Item name={[field.name, "baseUrl"]} label="接口地址"><Input /></Form.Item></Col>
<Col xs={24} md={12}><Form.Item name={[field.name, "apiKey"]} label="API Key"><Input.Password /></Form.Item></Col>
<Col span={24}><Form.Item name={[field.name, "models"]} label="渠道可用模型"><Select mode="tags" tokenSeparators={[",", "\n"]} /></Form.Item></Col>
<Col span={24}><Form.Item name={[field.name, "remark"]} label="备注"><Input.TextArea rows={2} /></Form.Item></Col>
</Row>
</div>
))}
<Button icon={<PlusOutlined />} onClick={() => add({ key: "", name: "", baseUrl: "", apiKey: "", models: [], enabled: true, remark: "" })}></Button>
</Flex>
)}
</Form.List>
</Card>
</Form>
) : (
<Card variant="borderless">
<Flex justify="space-between" align="center" gap={12} wrap style={{ marginBottom: 12 }}>
<Space>
<CodeOutlined />
<Typography.Text strong> JSON</Typography.Text>
{jsonError ? <Tag color="error">{jsonError}</Tag> : <Tag color="success" icon={<CheckCircleOutlined />}>JSON </Tag>}
</Space>
<Button icon={<FormatPainterOutlined />} onClick={formatJson}></Button>
</Flex>
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
<CodeMirror
value={jsonText}
height="560px"
extensions={[json(), jsonEditorTheme, syntaxHighlighting(jsonHighlightStyle)]}
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
theme="none"
onChange={setJsonText}
className="admin-json-editor"
style={{ fontSize: 13 }}
/>
</div>
</Card>
)}
</Flex>
</main>
);
}
function normalizeSettings(settings: Partial<AdminSettings> = {}): AdminSettings {
return {
public: {
...emptySettings.public,
...(settings.public || {}),
availableModels: settings.public?.availableModels || [],
},
private: {
channels: (settings.private?.channels || []).map((item) => ({
key: item.key || "",
name: item.name || "",
baseUrl: item.baseUrl || "",
apiKey: item.apiKey || "",
models: item.models || [],
enabled: Boolean(item.enabled),
remark: item.remark || "",
})),
},
};
}
function parseJsonSettings(value: string) {
try {
return normalizeSettings(JSON.parse(value) as AdminSettings);
} catch {
return null;
}
}
function getJsonError(value: string) {
try {
JSON.parse(value);
return "";
} catch (error) {
return error instanceof Error ? error.message : "JSON 格式不正确";
}
}
+30
View File
@@ -406,6 +406,36 @@
width: 26px;
}
.admin-json-editor .cm-editor {
background: var(--card);
color: var(--foreground);
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.admin-json-editor .cm-scroller {
font-family: inherit;
}
.admin-json-editor .cm-gutters {
background: color-mix(in srgb, var(--card) 92%, var(--foreground));
border-right: 1px solid var(--border);
color: var(--muted-foreground);
}
.admin-json-editor .cm-activeLine,
.admin-json-editor .cm-activeLineGutter {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.admin-json-editor .cm-cursor {
border-left-color: var(--foreground);
}
.admin-json-editor .cm-selectionBackground,
.admin-json-editor .cm-focused .cm-selectionBackground {
background: color-mix(in srgb, var(--primary) 28%, transparent);
}
@keyframes canvas-batch-child-in {
from {
opacity: 0;
+36
View File
@@ -81,3 +81,39 @@ export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>)
export async function deleteAdminAsset(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token);
}
export type AdminModelChannel = {
key: string;
name: string;
baseUrl: string;
apiKey: string;
models: string[];
enabled: boolean;
remark: string;
};
export type AdminPublicSettings = {
availableModels: string[];
defaultModel: string;
defaultImageModel: string;
defaultTextModel: string;
systemPrompt: string;
allowCustomModel: boolean;
};
export type AdminPrivateSettings = {
channels: AdminModelChannel[];
};
export type AdminSettings = {
public: AdminPublicSettings;
private: AdminPrivateSettings;
};
export async function fetchAdminSettings(token: string) {
return apiGet<AdminSettings>("/api/admin/settings", undefined, token);
}
export async function saveAdminSettings(token: string, settings: AdminSettings) {
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
}