refactor(admin): 重构管理员设置模型渠道配置结构

- 将公开设置中的模型相关字段统一收拢到 modelChannel 配置组
- 更新 AdminPublicSettings 类型定义为 AdminPublicModelChannelSettings
- 在前端表单中调整模型配置字段路径到 public.modelChannel 下
- 更新设置规范化函数以适配新的嵌套配置结构
- 修改依赖注入和文档以反映配置结构变化
- 添加系统配置数据结构文档说明新的配置格式
This commit is contained in:
HouYunFei
2026-05-21 12:14:56 +08:00
parent 603deee962
commit afd9631735
14 changed files with 151 additions and 58 deletions
+1
View File
@@ -32,6 +32,7 @@
## 前端规范
- 前端使用 Next.js App Router、React、TypeScript、Ant Design、Tailwind、Zustand。
- 编写 Ant Design 相关代码时,参考 https://ant.design/llms-full.txt 理解组件 API、示例和设计规范,并优先结合项目当前 antd 版本与既有写法。
- API 请求统一放在 `web/src/services/api/`
- 全局或跨页面状态优先放在 `web/src/stores/`
- 已经放在全局 store 或全局 hook 中的状态/动作,组件需要时直接使用对应 store/hook,不要为了“纯组件”层层透传 props;避免一个组件传递过多参数。
+1
View File
@@ -4,6 +4,7 @@
+ [新增] 管理后台新增系统设置页面,按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON。
+ [调整] 模型渠道配置改为列表和抽屉编辑,新增协议、权重、远程获取模型列表、模型清单测试和批量测试能力。
+ [调整] 公开系统配置中的模型相关字段统一收拢到 `modelChannel` 配置组。
+ [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。
+ [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。
+2 -1
View File
@@ -76,6 +76,7 @@ docker compose -f docker-compose.local.yml up -d --build
- [画布快捷键](docs/canvas-shortcuts.md)
- [待办事项](docs/todo.md)
- [后端数据库说明](docs/backend-database.md)
- [系统配置数据结构](docs/system-settings.md)
- [接口响应约定](docs/api-response.md)
## 社区支持
@@ -97,4 +98,4 @@ docker compose -f docker-compose.local.yml up -d --build
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&legend=top-left" />
</picture>
</a>
</a>
+6
View File
@@ -109,6 +109,12 @@
`public.value` 当前字段:
| 字段 | 类型 | 说明 |
|-------------------|----------|----------------|
| `modelChannel` | object | 模型渠道公开配置组 |
`modelChannel` 当前字段:
| 字段 | 类型 | 说明 |
|-------------------|----------|----------------|
| `availableModels` | string[] | 系统可用模型列表 |
+2
View File
@@ -4,7 +4,9 @@
- 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。
- Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。
- 后端应自动维护 `settings` 表,并支持公开读取 public 配置、管理员读取和保存 public/private 两行 JSON 配置。
- 公开系统配置中的模型相关字段应保存到 `modelChannel` 配置组下。
- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。
- 管理后台系统设置页面的公开配置应把私有配置中已启用渠道的模型列表作为系统可用模型的下拉选项,但系统可用模型仍由管理员手动选择。
- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。
- 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。
- 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。
+76
View File
@@ -0,0 +1,76 @@
# 系统配置数据结构
系统配置保存在 `settings` 表中,目前只使用两行:
| key | 说明 |
| --- | --- |
| `public` | 公开配置,前端可以读取 |
| `private` | 私有配置,只给后端和管理员使用 |
## public.value
```json
{
"modelChannel": {
"availableModels": ["gpt-5.5", "gpt-image-2"],
"defaultModel": "gpt-image-2",
"defaultImageModel": "gpt-image-2",
"defaultTextModel": "gpt-5.5",
"systemPrompt": "",
"allowCustomChannel": false
}
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `modelChannel` | object | 模型渠道公开配置组 |
`modelChannel` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 |
| `defaultModel` | string | 默认模型,从 `availableModels` 中选择 |
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 |
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 |
| `systemPrompt` | string | 系统提示词 |
| `allowCustomChannel` | boolean | 是否允许用户自定义渠道 |
## private.value
```json
{
"channels": [
{
"protocol": "openai",
"name": "默认渠道",
"baseUrl": "https://api.example.com",
"apiKey": "sk-xxx",
"models": ["gpt-5.5", "gpt-image-2"],
"weight": 1,
"enabled": true,
"remark": ""
}
]
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `channels` | object[] | 模型渠道列表 |
`channels` 每项字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `protocol` | string | 协议,当前为 `openai` |
| `name` | string | 渠道名称 |
| `baseUrl` | string | OpenAI 兼容接口地址 |
| `apiKey` | string | 渠道密钥 |
| `models` | string[] | 该渠道可用模型 |
| `weight` | number | 渠道权重;同一模型有多个可用渠道时按权重随机 |
| `enabled` | boolean | 是否启用 |
| `remark` | string | 备注 |
后端调用模型时,会从已启用、已配置 `baseUrl``apiKey`、且 `models` 包含目标模型的渠道中选择一个。
+7 -27
View File
@@ -1,7 +1,5 @@
package model
import "encoding/json"
type SettingKey string
const (
@@ -21,8 +19,8 @@ type ModelChannel struct {
Remark string `json:"remark"`
}
// PublicSetting 公开配置。
type PublicSetting struct {
// PublicModelChannelSetting 公开模型渠道配置。
type PublicModelChannelSetting struct {
AvailableModels []string `json:"availableModels"`
DefaultModel string `json:"defaultModel"`
DefaultImageModel string `json:"defaultImageModel"`
@@ -31,6 +29,11 @@ type PublicSetting struct {
AllowCustomChannel bool `json:"allowCustomChannel"`
}
// PublicSetting 公开配置。
type PublicSetting struct {
ModelChannel PublicModelChannelSetting `json:"modelChannel"`
}
// PrivateSetting 私有配置。
type PrivateSetting struct {
Channels []ModelChannel `json:"channels"`
@@ -49,26 +52,3 @@ type Settings struct {
Public PublicSetting `json:"public"`
Private PrivateSetting `json:"private"`
}
func (setting *PublicSetting) UnmarshalJSON(data []byte) error {
type alias struct {
AvailableModels []string `json:"availableModels"`
DefaultModel string `json:"defaultModel"`
DefaultImageModel string `json:"defaultImageModel"`
DefaultTextModel string `json:"defaultTextModel"`
SystemPrompt string `json:"systemPrompt"`
AllowCustomChannel bool `json:"allowCustomChannel"`
AllowCustomModel bool `json:"allowCustomModel"`
}
var value alias
if err := json.Unmarshal(data, &value); err != nil {
return err
}
setting.AvailableModels = value.AvailableModels
setting.DefaultModel = value.DefaultModel
setting.DefaultImageModel = value.DefaultImageModel
setting.DefaultTextModel = value.DefaultTextModel
setting.SystemPrompt = value.SystemPrompt
setting.AllowCustomChannel = value.AllowCustomChannel || value.AllowCustomModel
return nil
}
+2 -2
View File
@@ -30,8 +30,8 @@ func normalizeSettings(settings model.Settings) model.Settings {
}
func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting {
if setting.AvailableModels == nil {
setting.AvailableModels = []string{}
if setting.ModelChannel.AvailableModels == nil {
setting.ModelChannel.AvailableModels = []string{}
}
return setting
}
+5 -4
View File
@@ -6,6 +6,7 @@
"name": "infinite-canvas",
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/nextjs-registry": "^1.3.0",
"@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9",
@@ -43,7 +44,7 @@
"@ant-design/colors": ["@ant-design/colors@8.0.1", "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
"@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
"@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
"@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", "@rc-component/util": "^1.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA=="],
@@ -53,6 +54,8 @@
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="],
"@ant-design/nextjs-registry": ["@ant-design/nextjs-registry@1.3.0", "", { "peerDependencies": { "@ant-design/cssinjs": ">=1.0.0", "antd": ">=5.0.0", "next": ">=14.0.0", "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-lHlg5bnj3LPYHUD+iZsO1k11NhIrx1mLWh2DXQ0m3Ly3OSaYfgeHX0WoVXxdVjEGjjYnPTe+OUY7YvU/p1aPZw=="],
"@ant-design/pro-components": ["@ant-design/pro-components@3.0.0-beta.3", "https://registry.npmmirror.com/@ant-design/pro-components/-/pro-components-3.0.0-beta.3.tgz", { "dependencies": { "@ant-design/cssinjs": "^1.24.0", "@ant-design/icons": "^5.6.1", "@babel/runtime": "^7.27.6", "@ctrl/tinycolor": "^4.1.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.14.0", "@emotion/css": "^11.13.5", "@emotion/serialize": "^1.3.3", "@rc-component/util": "^1.2.2", "@umijs/route-utils": "^4.0.1", "@umijs/use-params": "^1.0.9", "classnames": "^2.5.1", "dayjs": "^1.11.13", "lodash-es": "^4.17.21", "path-to-regexp": "^6.3.0", "rc-field-form": "^2.7.0", "rc-footer": "^0.6.8", "rc-resize-observer": "^1.4.3", "rc-steps": "^6.0.1", "rc-table": "^7.51.1", "react-draggable": "^4.5.0", "react-layout-kit": "^1.9.2", "react-lazy-load": "^4.0.1", "react-markdown": "^8.0.7", "react-syntax-highlighter": "^15.6.1", "safe-stable-stringify": "^2.5.0", "shiki-es": "^0.2.0", "swr": "^2.3.4" }, "peerDependencies": { "antd": "^5.11.2", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-ehdIGlDcy4J5kv6E3fZ8e4YLQY4+7S3ZsIZnZ//vtfd+s0Qkv13hQ65CepoMiC/x6gNBlDnHozKANNi7JOtJAw=="],
"@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="],
@@ -1447,7 +1450,7 @@
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"@ant-design/cssinjs-utils/@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
"@ant-design/pro-components/@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
"@ant-design/pro-components/@ant-design/icons": ["@ant-design/icons@5.6.1", "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", { "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", "@babel/runtime": "^7.24.8", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg=="],
@@ -1501,8 +1504,6 @@
"accepts/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"antd/@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
"antd/@ant-design/icons": ["@ant-design/icons@6.2.3", "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.2.3.tgz", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.4.2", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg=="],
"babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/nextjs-registry": "^1.3.0",
"@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9",
+34 -20
View File
@@ -4,7 +4,7 @@ import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, PlusOutline
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, useState } from "react";
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";
@@ -26,12 +26,14 @@ const jsonEditorTheme = EditorView.theme({
const emptySettings: AdminSettings = {
public: {
availableModels: [],
defaultModel: "",
defaultImageModel: "",
defaultTextModel: "",
systemPrompt: "",
allowCustomChannel: false,
modelChannel: {
availableModels: [],
defaultModel: "",
defaultImageModel: "",
defaultTextModel: "",
systemPrompt: "",
allowCustomChannel: false,
},
},
private: { channels: [] },
};
@@ -58,7 +60,9 @@ export default function AdminSettingsPage() {
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const models = Form.useWatch(["public", "availableModels"], form) || [];
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]);
const activeMode = editorMode[activeTab];
const activeJsonText = jsonText[activeTab];
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
@@ -266,12 +270,12 @@ export default function AdminSettingsPage() {
activeMode === "visual" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<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", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked"><Switch /></Form.Item></Col>
<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={8}><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={8}><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={8}><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>
) : (
@@ -337,7 +341,7 @@ export default function AdminSettingsPage() {
</div>
)}
</Card>
<Drawer title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"} open={isChannelDrawerOpen} width={560} onClose={closeChannelDrawer} extra={<Space><Button onClick={closeChannelDrawer}></Button><Button type="primary" onClick={() => void saveChannel()}></Button></Space>} destroyOnHidden>
<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>
@@ -415,21 +419,23 @@ export default function AdminSettingsPage() {
}
function normalizeSettings(settings: Partial<AdminSettings> = {}): AdminSettings {
const privateSetting = normalizePrivateSetting(settings.private);
return {
public: {
...normalizePublicSetting(settings.public),
},
private: {
...normalizePrivateSetting(settings.private),
},
private: privateSetting,
};
}
function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}): AdminSettings["public"] {
return {
...emptySettings.public,
...setting,
availableModels: setting.availableModels || [],
modelChannel: {
...emptySettings.public.modelChannel,
...(setting.modelChannel || {}),
availableModels: setting.modelChannel?.availableModels || [],
},
};
}
@@ -452,6 +458,14 @@ function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChan
};
}
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(", ");
+8 -2
View File
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
import Script from "next/script";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { AppProviders } from "@/components/app-providers";
import "antd/dist/reset.css";
import "./globals.css";
@@ -22,12 +24,16 @@ export default function RootLayout({
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
}}
>
<script
<Script
id="theme-script"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `try{var s=JSON.parse(localStorage.getItem("infinite-canvas:theme_store")||"{}");var t=s.state&&s.state.theme==="light"?"light":"dark";document.documentElement.classList.toggle("dark",t==="dark");document.documentElement.style.colorScheme=t}catch(e){}`,
}}
/>
<AppProviders>{children}</AppProviders>
<AntdRegistry>
<AppProviders>{children}</AppProviders>
</AntdRegistry>
</body>
</html>
);
+1 -1
View File
@@ -32,7 +32,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
return {
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
cssVar: true,
cssVar: { key: dark ? "infinite-canvas-dark" : "infinite-canvas-light" },
token: {
colorPrimary: color.primary,
colorInfo: color.primary,
+5 -1
View File
@@ -95,7 +95,7 @@ export type AdminModelChannel = {
remark: string;
};
export type AdminPublicSettings = {
export type AdminPublicModelChannelSettings = {
availableModels: string[];
defaultModel: string;
defaultImageModel: string;
@@ -104,6 +104,10 @@ export type AdminPublicSettings = {
allowCustomChannel: boolean;
};
export type AdminPublicSettings = {
modelChannel: AdminPublicModelChannelSettings;
};
export type AdminPrivateSettings = {
channels: AdminModelChannel[];
};