From 46a32a957ea80caca5ced087ed599adb9002643d Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 18 Sep 2026 23:28:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=AB=99=E7=82=B9=E5=A4=96?= =?UTF-8?q?=E8=A7=82=20-=20=E6=94=AF=E6=8C=81=20Tab=20=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E4=B8=8E=E4=B8=BB=E9=A2=98=E6=B8=90=E5=8F=98=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 2 + backend/internal/app/appearance.go | 5 +- .../app/appearance_button_fill_test.go | 111 ++++ backend/internal/app/appearance_skins.go | 58 ++ .../content/docs/backend/backend-database.mdx | 8 +- docs/content/docs/backend/http-api.mdx | 27 + docs/content/docs/progress/pending-test.mdx | 45 ++ docs/design/site-appearance-and-skins.mdx | 14 +- web/src/components/layout/app-providers.tsx | 2 +- web/src/layouts/user-layout.tsx | 9 +- web/src/lib/app-theme.ts | 9 +- web/src/lib/skin-themes.ts | 67 +- .../pages/admin/components/admin-shell.tsx | 2 +- .../pages/admin/payments/payments-page.css | 18 +- .../settings/appearance-settings-page.tsx | 577 ++++++++++-------- .../settings/components/skin-theme-editor.tsx | 32 +- web/src/pages/admin/theme/admin-ant-theme.ts | 23 +- web/src/pages/admin/theme/admin-chrome.css | 1 - web/src/pages/create/creation-product.css | 6 +- web/src/stores/use-appearance-store.ts | 4 +- web/src/styles/globals.css | 93 ++- web/src/styles/primary-buttons.css | 14 + web/src/styles/workspace-product.css | 12 +- web/test/app-theme.test.ts | 41 ++ web/test/site-appearance-and-skins.test.ts | 8 +- web/test/skin-button-fill.test.ts | 55 ++ 27 files changed, 869 insertions(+), 375 deletions(-) create mode 100644 backend/internal/app/appearance_button_fill_test.go create mode 100644 web/src/styles/primary-buttons.css create mode 100644 web/test/app-theme.test.ts create mode 100644 web/test/skin-button-fill.test.ts diff --git a/.gitignore b/.gitignore index 7f62ecc83..c7798020c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ docs/content/docs/backend/* !docs/content/docs/backend/http-api.mdx !docs/content/docs/backend/code-map.mdx !docs/content/docs/backend/newapi-channel-2.mdx +!docs/content/docs/backend/video-token-billing.mdx !docs/content/docs/backend/creative-agent-runtime.mdx # Keep the creative Agent design versioned without unignoring generated docs. !docs/content/docs/overview/ diff --git a/AGENTS.md b/AGENTS.md index 61ae02ad2..2cce78452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,7 @@ - 第三方覆盖限定在具体组件,不新增全局 `.ant-modal-*`、`.dark .ant-switch-*`、`.ant-checkbox-*` 或 Segmented 状态补丁。新增 CSS 前先搜索同名选择器,回到唯一源规则修改。 - 遵循 `docs/ui-design-system.md` 及项目三层 token:Primitive → Semantic → Component。inline style 优先引用 `var(--token-name)`,不要散落颜色、圆角、阴影和层级字面值。 - 主操作、普通选中、Checkbox/Radio、Switch 是不同颜色角色;持久切换使用 `aria-pressed`,`type="primary"` 只表示当前主要命令。尊重 `prefers-reduced-motion`,键盘导航保留 `:focus-visible`。 +- 修改既有页面时直接修改真实组件、样式和路由。不得创建独立 HTML 来代替真实页面验证;只有用户明确要求原型或隔离设计稿时才可生成,并放在临时目录或用户指定位置,不得放入 `web/public/` 或产品构建目录。 ## 7. 本地开发、部署和数据目录 @@ -124,6 +125,7 @@ - 云端 Agent:前端事件流和审批路径依赖后端 `/api/agent` 接口;在该接口实现后补充后端冒烟、Worker 运行和 SSE 断线重连验证。 - 文档站:`cd docs && bun run types:check` 或 `bun run build`。 - UI 变更能浏览器验证时,检查关键路由、明暗主题、滚动、弹窗、空态和核心交互;不能验证时说明替代依据,不把静态阅读或 `git diff` 写成运行验证。 +- 验证必须针对真实实现:优先专项测试、类型检查、项目 build 或真实路由浏览器验收。独立 HTML、静态仿制页面和脱离应用状态的截图不能作为真实页面通过的证据。 同类失败连续三次时停止盲试,记录现象、已排除项和新假设,再切换路径或请求用户决策。 diff --git a/backend/internal/app/appearance.go b/backend/internal/app/appearance.go index be40f3328..6cc40b730 100644 --- a/backend/internal/app/appearance.go +++ b/backend/internal/app/appearance.go @@ -31,7 +31,7 @@ const ( ) const ( - appearanceSchemaVersion = 7 + appearanceSchemaVersion = 8 appearanceLogoMaxBytes int64 = 5 << 20 appearancePosterMaxBytes int64 = 10 << 20 appearanceVideoMaxBytes int64 = 256 << 20 @@ -346,6 +346,9 @@ func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, err return nil, AppearanceSetting{}, err } value := defaultAppearanceSetting() + // Decode stored themes without reusing built-in slice entries: omitted + // fields must be migrated according to each stored theme's identity. + value.SkinThemes = nil if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil { return nil, AppearanceSetting{}, errors.New("外观配置格式无效") } diff --git a/backend/internal/app/appearance_button_fill_test.go b/backend/internal/app/appearance_button_fill_test.go new file mode 100644 index 000000000..bb380d7ed --- /dev/null +++ b/backend/internal/app/appearance_button_fill_test.go @@ -0,0 +1,111 @@ +package app + +import ( + "encoding/json" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestAppearanceButtonFillRoundTripAndValidation(t *testing.T) { + svc, _, _, admin := newAppearanceTestService(t) + themes := defaultAppearanceSkinThemes() + custom := cloneAppearanceSkin(themes[0], "custom-gradient", "渐变主题", "主按钮渐变") + custom.Tokens.Buttons.Light.Angle = 45 + custom.Tokens.Buttons.Light.Start = "#123456" + custom.Tokens.Buttons.Dark.Mode = "solid" + themes = append(themes, custom) + input := AppearanceSetting{BrandName: "测试站点", BrandSlug: "test", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: custom.ID, SkinThemes: themes} + saved, err := svc.UpdateAppearance(admin, input) + if err != nil { + t.Fatal(err) + } + if saved.Public.ActiveSkin.Tokens.Buttons != custom.Tokens.Buttons { + t.Fatal("public projection lost button parameters") + } + loaded, err := svc.AdminAppearance(admin) + if err != nil { + t.Fatal(err) + } + if loaded.Public.ActiveSkin.Tokens.Buttons != custom.Tokens.Buttons { + t.Fatal("stored button parameters changed") + } + for name, mutate := range map[string]func(*AppearanceSkinButtonFill){ + "mode": func(fill *AppearanceSkinButtonFill) { fill.Mode = "image" }, + "negative angle": func(fill *AppearanceSkinButtonFill) { fill.Angle = -1 }, + "large angle": func(fill *AppearanceSkinButtonFill) { fill.Angle = 361 }, + "css injection": func(fill *AppearanceSkinButtonFill) { fill.End = "red; background:url(x)" }, + "missing color": func(fill *AppearanceSkinButtonFill) { fill.Foreground = "" }, + } { + t.Run(name, func(t *testing.T) { + bad := input + bad.SkinThemes = append([]AppearanceSkinTheme(nil), themes...) + mutate(&bad.SkinThemes[len(themes)-1].Tokens.Buttons.Light) + if _, err := svc.UpdateAppearance(admin, bad); err == nil { + t.Fatal("invalid fill accepted") + } + }) + } + input.SkinThemes[0].Tokens.Buttons.Light.Start = "#000000" + if _, err := svc.UpdateAppearance(admin, input); err == nil { + t.Fatal("classic fill mutation accepted") + } +} + +func TestAppearanceLegacyButtonFills(t *testing.T) { + themes := defaultAppearanceSkinThemes() + for index := range themes { + themes[index].Tokens.Buttons = AppearanceSkinButtons{} + } + migrated := normalizeAppearanceSkinThemes(themes) + if err := validateAppearanceSkinThemes(migrated, "classic"); err != nil { + t.Fatal(err) + } + for _, skin := range migrated { + expected := "solid" + if skin.ID == "classic" { + expected = "gradient" + } + if skin.Tokens.Buttons.Light.Mode != expected || skin.Tokens.Buttons.Dark.Mode != expected { + t.Fatalf("wrong fill for %s", skin.ID) + } + } + var fill AppearanceSkinButtonFill + if err := json.Unmarshal([]byte(`{"angle":1.5}`), &fill); err == nil { + t.Fatal("fractional angle accepted") + } +} + +func TestAppearanceReadsLegacyButtonFillsByStoredIdentity(t *testing.T) { + svc, db, _, admin := newAppearanceTestService(t) + classic := defaultClassicAppearanceSkin() + custom := cloneAppearanceSkin(classic, "custom-legacy", "旧主题", "保持纯色") + encoded, err := json.Marshal([]AppearanceSkinTheme{custom, classic}) + if err != nil { + t.Fatal(err) + } + var themes []map[string]any + if err := json.Unmarshal(encoded, &themes); err != nil { + t.Fatal(err) + } + for _, skin := range themes { + delete(skin["tokens"].(map[string]any), "buttons") + } + encoded, err = json.Marshal(map[string]any{"schemaVersion": 7, "skinId": custom.ID, "skinThemes": themes}) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil { + t.Fatal(err) + } + loaded, err := svc.AdminAppearance(admin) + if err != nil { + t.Fatal(err) + } + if loaded.Public.ActiveSkin.Tokens.Buttons.Light.Mode != "solid" || loaded.SkinThemes[1].Tokens.Buttons.Light.Mode != "gradient" { + t.Fatal("legacy fields inherited another theme's fill") + } + if err := validateAppearanceSkinThemes(loaded.SkinThemes, custom.ID); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/app/appearance_skins.go b/backend/internal/app/appearance_skins.go index 31dd71f7c..8cae965f5 100644 --- a/backend/internal/app/appearance_skins.go +++ b/backend/internal/app/appearance_skins.go @@ -96,6 +96,35 @@ type AppearanceSkinTokens struct { Light AppearanceSkinModeTokens `json:"light"` Dark AppearanceSkinModeTokens `json:"dark"` Components AppearanceSkinComponentTokens `json:"components"` + Buttons AppearanceSkinButtons `json:"buttons"` +} + +type AppearanceSkinButtons struct { + Light AppearanceSkinButtonFill `json:"light"` + Dark AppearanceSkinButtonFill `json:"dark"` +} + +type AppearanceSkinButtonFill struct { + Mode string `json:"mode"` + Angle int `json:"angle"` + Start string `json:"start"` + End string `json:"end"` + HoverStart string `json:"hoverStart"` + HoverEnd string `json:"hoverEnd"` + ActiveStart string `json:"activeStart"` + ActiveEnd string `json:"activeEnd"` + Foreground string `json:"foreground"` +} + +func defaultAppearanceSkinButtons(gradient bool) AppearanceSkinButtons { + fill := AppearanceSkinButtonFill{ + Mode: "solid", Angle: 115, Start: "#6554df", End: "#386fbc", + HoverStart: "#5744cf", HoverEnd: "#356bbb", ActiveStart: "#4938b8", ActiveEnd: "#2c5da5", Foreground: "#ffffff", + } + if gradient { + fill.Mode = "gradient" + } + return AppearanceSkinButtons{Light: fill, Dark: fill} } type AppearanceSkinTheme struct { @@ -159,6 +188,9 @@ func defaultAppearanceSkinThemes() []AppearanceSkinTheme { }) violet.Tokens.Components = appearanceSkinComponentPreset(7, 7, 12, 14, 9, 4) + studio.Tokens.Buttons = defaultAppearanceSkinButtons(false) + warm.Tokens.Buttons = defaultAppearanceSkinButtons(false) + violet.Tokens.Buttons = defaultAppearanceSkinButtons(false) return []AppearanceSkinTheme{classic, studio, warm, violet} } @@ -181,6 +213,7 @@ func defaultClassicAppearanceSkin() AppearanceSkinTheme { AdminBackground: "#101010", AdminSurface: "#181818", AdminSubtle: "#202020", AdminStrong: "#2a2a2a", AuthBackground: "#08090c", AuthPanel: "#0b0c10", AuthCard: "#121318", AuthAccent: "#93c5fd", AuthMuted: "#8a8b91", }, Components: appearanceSkinComponentPreset(6, 6, 12, 12, 8, 4), + Buttons: defaultAppearanceSkinButtons(true), }, } } @@ -234,6 +267,11 @@ func normalizeAppearanceSkinThemes(themes []AppearanceSkinTheme) []AppearanceSki result[index].Name = strings.TrimSpace(result[index].Name) result[index].Description = strings.TrimSpace(result[index].Description) result[index].Locked = result[index].ID == defaultAppearanceSkinID + // Only a wholly absent legacy button block is upgraded. Partial or + // malformed submitted parameters remain invalid on the write path. + if result[index].Tokens.Buttons == (AppearanceSkinButtons{}) { + result[index].Tokens.Buttons = defaultAppearanceSkinButtons(result[index].Locked) + } var fallback AppearanceSkinTokens for _, builtin := range builtins { if builtin.ID == result[index].ID { @@ -325,6 +363,11 @@ func validateAppearanceSkinThemes(themes []AppearanceSkinTheme, selectedID strin if err := validateAppearanceSkinComponents(skin.Tokens.Components); err != nil { return err } + for _, fill := range []AppearanceSkinButtonFill{skin.Tokens.Buttons.Light, skin.Tokens.Buttons.Dark} { + if err := validateAppearanceSkinButtonFill(fill); err != nil { + return err + } + } } if !foundClassic { return BadAuthRequest("经典黑白为系统默认主题,不能修改或删除") @@ -345,6 +388,21 @@ func validateAppearanceSkinMode(mode AppearanceSkinModeTokens) error { return nil } +func validateAppearanceSkinButtonFill(fill AppearanceSkinButtonFill) error { + if fill.Mode != "solid" && fill.Mode != "gradient" { + return BadAuthRequest("主按钮填充模式无效") + } + if fill.Angle < 0 || fill.Angle > 360 { + return BadAuthRequest("主按钮渐变角度必须在 0 到 360 之间") + } + for _, color := range []string{fill.Start, fill.End, fill.HoverStart, fill.HoverEnd, fill.ActiveStart, fill.ActiveEnd, fill.Foreground} { + if !appearanceColorPattern.MatchString(color) { + return BadAuthRequest("主按钮颜色必须使用 6 或 8 位十六进制颜色") + } + } + return nil +} + func validateAppearanceSkinComponents(value AppearanceSkinComponentTokens) error { for _, candidate := range []struct { value, min, max int diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 4ff03adb9..8fe262d8c 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -23,7 +23,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 23。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。已发布版本的实现和校验和不得修改。 +当前结构版本为 26。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。版本 24 `channel_model_label` 为渠道模型增加 `channel_label`(最长 80 字、非空、默认空字符串),不重写模型标识、上游 ID、价格或能力;版本 25 `video_token_formula_snapshot` 为账单增加视频公式用量及用量来源快照。版本 26 `channel_model_description` 为渠道模型增加 `description`(最长 500 字、非空、默认空字符串),供二级选项展示使用说明,不重写身份或价格。已发布版本的实现和校验和不得修改。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 @@ -86,6 +86,8 @@ docker compose -f docker-compose.deploy.yml up -d backend web ## 平台外观配置 +当前外观 JSON 合同版本为 8,主题参数新增 `skinThemes[].tokens.buttons.light/dark`:填充模式、角度、常态/悬停/按下双色和文字色。经典主题默认为紫蓝渐变,其余旧主题缺少此块时保持纯色;公开 `activeSkin` 使用相同结构。无表结构迁移,读取补齐只在内存执行,管理员保存时写回。回退应用版本前保留外观 JSON 备份,旧版本不支持新按钮填充参数。 + 平台外观复用既有 `system_settings` 表,不新增结构迁移。键 `appearance` 的 JSON 值使用版本化合同;版本 3 保存品牌名称、英文品牌标识、登录页主标题与可选说明文案、浅色与可选深色 Logo 资源 ID、Logo 外框开关、登录页视频资源 ID、可选封面资源 ID 和预留皮肤 ID。只配置一套 Logo 时,公开投影会在浅色和深色主题复用同一资源;旧版本缺少深色 Logo 与外框字段时分别回退到单 Logo 和启用外框,不改变既有视觉。资源元数据和文件仍分别由 `resources` 表与当前资源存储承担。恢复默认外观时删除这条设置记录并回到内置值,不级联删除已经上传的资源。 公开接口只投影品牌名、英文品牌标识、登录页公开文案、深浅主题使用的稳定 slot 资源 URL、Logo 外框状态、配置状态和修订标识,不返回内部资源 ID、对象路径或存储凭据。管理员保存新配置前,服务层会校验文本长度与安全字符、资源为就绪状态、类型与 slot 匹配,且新绑定资源由当前管理员上传;两套 Logo、视频和封面中任何正在使用的资源都会进入管理员删除保护。品牌变更同时写入 `admin_audit_events`。 @@ -187,10 +189,12 @@ docker compose -f docker-compose.deploy.yml up -d backend web | 表 | 用途 | 关键边界 | | --- | --- | --- | | `model_channels` | 保存系统渠道与用户渠道的连接配置,以及系统渠道前台别名和排序 | 系统渠道密钥不进入公开目录;公开名称优先使用 `public_alias`,为空时使用原名;按 `sort_order, created_at, id` 升序展示 | -| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、模型 Logo、排序、能力、协议和版本状态 | 模型排序独立保存,不递增价格或能力版本;能力、协议、启用状态和价格仍由 service 强校验 | +| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、渠道展示名、模型描述、模型 Logo、排序、能力、协议和版本状态 | `channel_label` 最长 80 字,空值沿用渠道公开名称;`description` 最长 500 字、非空、默认空字符串,用于二级菜单说明;同一渠道内的产品模型标识仍唯一,跨渠道同能力同标识可在前端归组;模型排序独立保存,不递增价格或能力版本 | | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录。档位 `provider_model_key` 非空时优先于渠道模型上游键发往供应商;管理端修改模型级上游 ID 时,与旧值相同的档位键自动级联跟随,其余显式 SKU 保持不变 | | `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 | +所有视频协议开放 Token 定价。`channel_model_price_tiers.selector_json` 支持 `videoGenerateAudio: "true" | "false"`,省略表示任意音频;参考视频仍匹配 `operation: "video_to_video"`。视频仅使用输出 Token 单价,输入和缓存价必须为 0,明确配置的零价表示免费。schema v25 为 `billing_orders` 新增 `video_formula_tokens`(非空、默认 0,提交时固化不含余量的公式用量)及 `usage_source`(非空、默认空字符串,`provider` 或 `video_formula`)。缺少有效上游用量的新视频订单按公式快照结算并记录 `usage_available=false`,不伪造上游用量;历史快照为 0 的订单保持旧规则。价格档、费率、倍率、预留金额和既有账单不被迁移重算。前后端和 schema 一起升级,旧程序不支持新公式结算及非 Ark Token 配置,回滚需匹配数据库备份与二进制版本。完整算法见[视频 Token 计费](/docs/backend/video-token-billing)。 + 图片分档尺寸保存在现有模型能力 JSON 的 `image.size.presets` 中,不新增表、列或 SQL 迁移。每项结构为 `{ tier, ratio, size, width, height }`,例如 `{ "tier": "1k", "ratio": "16:9", "size": "1824x1024", "width": 1824, "height": 1024 }`。 - `tier` 仅允许 `1k`、`2k`、`4k`,管理员分别配置各档支持的比例;新增比例自动计算像素,原有精确尺寸不被重算。 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index d94cfbf40..7647f7285 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -9,6 +9,33 @@ description: 信封、错误码与 OpenAPI 机器可读规范:运行中的 `GET /api/openapi.yaml`,源文件 `backend/internal/handler/openapi.yaml`。前端 TypeScript 仍走 `web/src/services/api` 的 `http`,不从 OpenAPI 生成客户端。 +## 站点皮肤主按钮 + +`GET /public/appearance` 返回当前 `activeSkin`;管理员通过 `GET/PUT /admin/settings/appearance` 读取、保存完整主题库。外观合同版本 8 新增 `tokens.buttons.light/dark`,字段为 `mode`(`solid|gradient`)、整数 `angle`(0–360)、HEX 色值 `start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd`、`foreground`。纯色模式使用既有主操作颜色,渐变仅用于主按钮。非法模式、角度、色值或修改锁定经典主题均拒绝保存;整块缺失的旧配置按主题身份补齐。此变更不增加数据表迁移。 + +## 模型规格报价 + +`GET /model-catalog` 在 `source=system` 时返回 `channels[].models[]`。每项的 `modelKey` 是产品模型标识,`displayName` 是产品展示名,`icon` 是模型图标键,`channelLabel` 是可选的渠道展示名(默认空字符串)。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白并限制为 80 字,空字符串表示恢复使用渠道公开名称。 + +渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项在悬浮、聚焦或选中时展示该字段,长内容可悬浮查看完整文本;未填写的系统模型不按名称生成宣传文案。描述区域保留固定行高,悬浮不改变菜单项尺寸。 + +创作端按 `capability + modelKey` 精确分组,二级菜单保留各渠道模型的独立选择;选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示名、渠道展示名和 Logo 不参与路由、权限及计费。`source=frontend` 的逻辑模型目录继续按原逻辑模型身份工作,不将逻辑模型的供应线路公开成可选渠道。 + +同一产品在不同渠道中的 `modelKey` 应保持一致,名称与 Logo 也应统一;不同产品版本使用不同标识。已有模型标识不会自动改写。一级顺序按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道顺序;若历史配置的展示名或 Logo 不一致,以稳定渠道身份排序选取元数据,不随当前选择和渠道排序跳变。 + +以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。 + +| 接口 | 请求 | +| --- | --- | +| `POST /model-catalog/quote` | `{ channelId, modelKey, intent }`,仅允许启用的系统渠道与可用模型,复用任务准入、默认参数和价格档选择 | +| `POST /models/:id/quote` | 直接提交 `intent`,按前台逻辑模型当前可用线路和价格策略报价 | + +`intent` 包含 `capability`(`text/image/video/audio`)、`operation`、`inputs`(素材类型到数量的映射)和 `options`(请求规格)。素材数量必须为 0–128 的整数,再按模型能力限制校验。视频规格包括 `vquality`、`size`、`videoSeconds`、布尔值 `videoGenerateAudio`,显式 `false` 必须保留。 + +报价包含 `logicalModelId`(系统渠道直连为空)、`billingMode`、`quantity`、`amountMicrocredits`、`estimated`。视频 Token 报价另有 `videoTokenEstimate`:`formulaTokens`、`reservedTokens`、`outputWidth`、`outputHeight`、`framesPerSecond`、`outputSeconds`、`referenceSeconds`、`referenceDurationEstimated`、`dimensionsEstimated`、`reservationMarginPercent`。 + +Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考素材数量不包含时长信息,带参考视频时统一按 15 秒估算并返回 `referenceDurationEstimated: true`。所有视频协议均支持 Token 定价;10% 预留是平台策略,不是费用上限。成功后优先有效上游用量,无有效用量时按提交的公式快照结算,余量不计入公式费用。账单新增 `videoFormulaTokens` 及 `usageSource`(`provider` / `video_formula`),公式结算的 `usageAvailable` 为 `false`,`outputTokens` 表示公式数量。算法、配置与异常用量处理见[视频 Token 计费](/docs/backend/video-token-billing)。 + ## 画布保存、历史与恢复 画布内容使用数据库递增的 `revision` 做乐观并发校验;`updatedAt` 仅用于展示,不作为版本依据。新建 `PUT /canvas-projects/:id` 的 `project.revision` 为 0,已有画布必须带上读取到的版本。数据库在同一次条件更新中匹配 ID、用户与版本,成功后返回 `data.project` 摘要及递增后的版本;缺失版本返回 428,陈旧版本返回 409,非法版本返回 400。客户端不能获取新版号后直接重发旧内容。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 84bd13ab0..9e3babab6 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,51 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 站点皮肤、控件焦点与外观分类 + +主按钮填充改为主题参数统一管理,经典黑白采用黑白基础色与 115° 紫蓝渐变;复制主题后可分别编辑明暗模式的纯色/渐变、角度、常态/悬停/按下双色和文字色。创作页“开始创作”与前后台主按钮使用相同参数,危险、幽灵和禁用按钮不套用渐变。后台隔离用户工作台外壳与浮层样式。输入、选择、日期和搜索控件移除重复焦点阴影,保留键盘焦点与错误状态。站点及外观按品牌、登录页、欢迎页、SEO、页尾备案、皮肤六个 Tab 展示,页面草稿统一保存,欢迎页开关继续独立即时保存。 + +- [x] 前端生产构建、后续类型检查及 9 项主题/渐变专项通过,覆盖主按钮状态、纯色切换清理、复制隔离和非法参数降级。后端外观专项通过,覆盖保存/读取/公开投影、旧数据补齐、非法参数拒绝及经典主题保护。外观 JSON 升为版本 8,无表结构迁移。 +- [x] 隔离浏览器在真实外观路由验证跨 Tab 草稿保留、撤销、校验定位、隐藏视频卸载,以及明暗模式下经典/自定义按钮常态和悬停颜色、输入框无阴影、键盘焦点可见及 390px 无横向溢出。会话与配置使用测试状态,未保存真实站点设置。 +- [x] 渐变扩展后,真实创作页和插件页验证经典主按钮明暗配色;后台编辑器验证纯色/渐变切换、角度修改、跨 Tab 草稿保留、撤销后保存按钮禁用、危险按钮不套用渐变。修复旧配置归一化后误报未保存状态;选择框键盘导航使用可见外框。浏览器 API 为隔离测试配置,持久化由后端临时 SQLite 服务测试验证。 +- [ ] 用真实管理账号保存主题并刷新前后台,确认常态、悬停、按下、禁用、危险操作和浮层控件配色一致;验证媒体文件跨 Tab 选择后保存与刷新回填。 +- [ ] 渐变相关三文件回归为 16 通过、1 失败,失败为原有后台旧警告色别名断言。上一轮完整相关回归还发现模型编辑器网格 CSS 顺序、首屏内置 Logo 引用、BrandLogo 旧主题 hook 三项原有失败;未修改这些无关实现。 + +## 模型描述与稳定悬浮 + +后台渠道模型编辑的“模型身份”区新增“模型描述”,公开目录和会话配置透传到二级选项。说明行预留固定高度,一级菜单取消悬浮位移,二级选项取消高度动画;选中、悬浮及键盘聚焦仍显示说明。系统模型未填写描述时不生成名称推测文案。 + +- [x] 前端 21 项专项测试及含类型检查的生产构建通过;后端保存/公开投影/描述长度专项、数据库包全套迁移回归及接口路由/OpenAPI 冒烟通过。 +- [x] 隔离浏览器加载真实选择器,连续六次切换悬浮的位置和尺寸一致;已配置/空描述、聚焦显示、渠道选择、键盘、明暗图标及 390px 无横向溢出通过。真实编辑组件验证描述回填、修改后提交去空白文本、清空后提交空字符串;接口被测试拦截,未写真实模型。 +- [ ] 在真实管理账号下保存、重新打开并清空描述,刷新创作端确认内容同步;在画布与创作页确认长描述、明暗主题与键盘选择。 +- [ ] PostgreSQL 部署验证 schema v26。此次迁移只添加默认空字符串列;关闭自动迁移时先执行 `migrate-schema up` 和 `migrate-schema verify`。不提供降级迁移,回退使用匹配的数据库备份与二进制版本;未操作实际部署数据库。 + +## 产品模型分组与渠道展示名 + +系统渠道目录按能力与产品模型标识分组,二级展示独立渠道名称和价格;后台新增“渠道展示名”,留空使用渠道公开名称。选中值仍为渠道 ID 与模型标识,逻辑模型及个人渠道保持原选择合同。模型 Logo 按需加载原色版本,移除菜单位置染色。 + +- [x] 前端专项覆盖同产品多个渠道、价格透传、名称变化不改调用身份、能力隔离、排序时元数据稳定,以及逻辑模型和个人渠道边界。 +- [x] 后端 SQLite 专项覆盖字段保存、公开目录及会话投影、长度与管理员校验;版本 24 迁移重复执行保留旧记录的身份和价格。 +- [x] 前端构建与类型检查通过;隔离浏览器在现有应用中加载真实选择器组件,验证 3 个渠道及 0.3/0.3/0.2 积分单价、鼠标和键盘选中身份、Esc 关闭、390px 无横向溢出,以及明暗主题下图标原色保持一致。使用测试配置,未修改真实渠道。 +- [x] 数据库包全套回归通过;旧迁移测试夹具补齐基线应有的 `billing_orders`,覆盖 v24、v25 连续升级。 +- [ ] 使用实际配置确认跨渠道的产品模型标识、展示名和 Logo 一致,填写各渠道展示名后验证画布、创作页与 Agent 模型入口。 +- [ ] 在 PostgreSQL 部署执行版本 24 迁移并验证目录;迁移只新增默认空字符串列,不自动规范化现有产品模型标识。关闭自动迁移的部署先运行 `migrate-schema up` 与 `migrate-schema verify`;没有降级迁移,回退需匹配数据库备份与二进制版本。 + +## 全协议视频 Token 计费闭环 + +所有视频协议开放 Token 价格,包含 NewAPI 通道 2;系统渠道和前台逻辑模型共用报价与火山式 24 FPS 公式。平台额外预留 10%,成功后优先有效上游用量,无有效用量时按提交的公式快照结算并退回余量,账单标注公式来源。schema v25 新增公式快照和来源列,不重算历史订单。方案与回滚边界见[视频 Token 计费](/docs/backend/video-token-billing)。 + +- [x] 后端专项覆盖官方公式、不同代际像素、4K、参考时长非法值、音频分档、免费价、真实用量严格解析、账单退差和补扣。 +- [x] SQLite 集成测试覆盖系统渠道报价与任务预留一致、两种逻辑模型价格策略、报价无账户/账本/任务写入、不可用渠道拒绝与非法素材数量拒绝。 +- [x] 收尾回归覆盖内部模型别名下的报价/预留一致、统一价格保存强校验,以及最高合法单价、倍率、最终金额溢出和汇总后一次取整;预估与结算共用精确金额算法。 +- [x] 前端专项覆盖表单保存/回填、音频 false 匹配、Token 单价摘要、新旧报价接口与预估边界文案。 +- [x] NewAPI 通道 2、NewAPI 视频及自定义视频的 SQLite 集成覆盖报价、预留、无上游用量公式结算、退回 10% 余量和重复结算幂等;v25 迁移重复执行保留历史账单。 +- [x] 最新后端 app/repository/database 视频计费专项、数据库包全套回归通过;前端 41 项计费测试、`bun run build` 和文档 `bun run types:check` 通过。未执行真实收费生成或真实数据库迁移。 +- [ ] 登录管理端配置有声/无声及参考视频价格档,在模型菜单、画布与短剧生产页变更时长/清晰度/音频,确认当前参数报价更新,切换免费档显示 0。 +- [ ] 用真实方舟与 Agent Plan 渠道完成视频任务,核对上游 `completion_tokens`、账单实际金额及退补差额;参考视频最低消耗可能高于公式预估,确认用户能查看预估边界说明。 +- [ ] 使用 Artbox 的 NewAPI 通道 2 保存“视频 Token”,完成无用量视频任务后确认公式结算、10% 余量退回、来源标注;重复轮询/回调不重复扣费。 +- [ ] PostgreSQL 部署验证 schema v25;新代码未启动或未迁移时不要用旧后端保存非 Ark Token 价格。历史无公式快照的收费订单缺少用量仍进入待核对。 + ## Agent 历史事件重放导致画布反复冲突 旧会话的画布增量可能缺少版本号。页面刷新重放事件时,原实现先标记冲突;后续完整快照即使确认内容一致,也提前返回而不清除冲突。现在增量拒绝只触发快照校准,由完整快照确认真实冲突;内容一致时更新同步基线并恢复已同步状态。云端仍为已核对的同一基线时,保留待保存的本地编辑,不误判为并发修改。真实冲突继续保留草稿并阻止覆盖,不重放无版本增量,不修改历史数据。 diff --git a/docs/design/site-appearance-and-skins.mdx b/docs/design/site-appearance-and-skins.mdx index d4261c955..5c03c91c5 100644 --- a/docs/design/site-appearance-and-skins.mdx +++ b/docs/design/site-appearance-and-skins.mdx @@ -2,7 +2,7 @@ ## 1. 目标与边界 -本模块让部署方在不修改源码的前提下管理站点身份、登录页内容、搜索摘要、首页收尾信息与全站皮肤。默认 `classic` 皮肤必须等同项目既有样式;自定义品牌不得在首次渲染、邮件、诊断包、素材插件或后台说明中短暂或持续露出内置品牌。 +本模块让部署方在不修改源码的前提下管理站点身份、登录页内容、搜索摘要、首页收尾信息与全站皮肤。默认 `classic` 皮肤采用黑白基础配色与紫蓝渐变主按钮;自定义品牌不得在首次渲染、邮件、诊断包、素材插件或后台说明中短暂或持续露出内置品牌。 技术兼容标识不属于品牌文案:仓库名、容器/升级器路径、剪贴板协议、localStorage key、插件 API version、数据库基线校验值和 User-Agent 可以继续使用 `open-ai-canvas` 或 `infinite-canvas`。英文品牌标识只用于新资源路径建议,不能自动改写已有对象存储前缀或历史资源。 @@ -17,7 +17,7 @@ | 首页收尾 | 版权信息、备案开关、备案号 | 备案号只有在显式开启且非空时显示,并链接工信部备案系统 | | 皮肤 | `skinId`、`skinThemes` | 选择当前皮肤并维护主题库;每套同时定义亮色、暗色与组件参数 | -配置继续存储在 `system_settings.appearance` JSON 中,schema version 为 6,不增加关系型数据库迁移。旧配置缺失 SEO、版权、备案、主题库、独立开关状态或破坏性操作交互色时,运行时会按原有主色、控件色和危险色向后补齐;只有管理员保存后才写回数据库。管理接口返回完整 `skinThemes`,公开接口只返回当前 `activeSkin`,避免将未启用的运营草稿暴露给前台。 +配置继续存储在 `system_settings.appearance` JSON 中,schema version 为 8,不增加关系型数据库迁移。旧配置缺失 SEO、版权、备案、主题库、独立开关状态或破坏性操作交互色时,运行时会按原有主色、控件色和危险色向后补齐;只有管理员保存后才写回数据库。管理接口返回完整 `skinThemes`,公开接口只返回当前 `activeSkin`,避免将未启用的运营草稿暴露给前台。 ## 3. 皮肤架构 @@ -28,7 +28,11 @@ 3. Component:按钮、输入、卡片、弹层、菜单与复选框圆角,以及控件高度、描边、焦点环、图标、字重、悬停抬升、动效和阴影。 4. Adapter:将同一份语义令牌同时映射到 CSS Custom Properties 和 Ant Design 全局/组件 token。 -皮肤合同位于 `backend/internal/service/appearance_skins.go`,前端映射位于 `web/src/lib/skin-themes.ts`。页面不得按皮肤 ID 写条件样式;业务组件只能消费语义 token。`classic` 在运行时不写覆盖变量,使既有 `globals.css` 与 `app-theme.ts` 保持原样式唯一事实源;服务端会逐字段比对经典主题,任何删除或伪造修改都会被拒绝。其他主题均为可编辑数据,用根节点 CSS 变量覆盖自研组件语义层,并向 Ant Design `ConfigProvider` 提供同一套颜色、交互状态、圆角、尺寸、动效和阴影值。 +皮肤合同位于 `backend/internal/app/appearance_skins.go`,前端映射位于 `web/src/lib/skin-themes.ts`。页面不得按皮肤 ID 写条件样式;业务组件只能消费语义 token。`classic` 只注入主按钮填充变量,基础表面继续由 `globals.css` 与 `app-theme.ts` 提供;服务端会逐字段比对经典主题,任何删除或伪造修改都会被拒绝。其他主题均为可编辑数据,用根节点 CSS 变量覆盖自研组件语义层,并向 Ant Design `ConfigProvider` 提供同一套颜色、交互状态、圆角、尺寸、动效和阴影值。 + +`tokens.buttons.light/dark` 分别配置主按钮填充:`mode` 为 `solid` 或 `gradient`,`angle` 为 0–360 的整数;`start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd` 和 `foreground` 为 HEX 色值。纯色模式消费原有 `primary/primaryHover/primaryActive/primaryForeground`,渐变模式使用独立参数,不改变链接、选中态、开关或危险操作。经典默认角度为 115°,常态 `#6554df → #386fbc`,悬停 `#5744cf → #356bbb`,按下 `#4938b8 → #2c5da5`,文字为白色。后台可复制经典主题后调整两种模式的全部参数。 + +旧数据整块缺少 `buttons` 时,经典主题补入默认渐变,其余主题保留纯色;局部缺失或非法写入被拒绝。读取补齐不自动写库,保存后写入版本 8;回退旧版本前应备份外观 JSON,旧版本不会呈现新渐变配置。 主题库初始包含: @@ -73,7 +77,9 @@ SEO 标题建议控制在 70 字符内,描述 200 字符内,关键词 300 ## 6. 后台交互合同 -后台入口名称为“站点及外观”,继续复用系统设置页的页面框架、状态操作栏、`SettingsSectionCard`、Ant Form/Input/Switch 与统一保存模型。五个顺序固定为:品牌识别、登录页内容与媒体、SEO 信息、首页收尾与备案、皮肤主题。主题编辑器由主题库、身份编辑、浅/深模式切换、实时控件预览、语义颜色分组和组件参数分组组成;默认主题展示锁定原因,自定义主题显示复制与删除动作。 +后台入口名称为“站点及外观”,继续复用系统设置页的页面框架、状态操作栏、`SettingsSectionCard`、Ant Form/Input/Switch 与统一保存模型。内容按六个 Tab 展示:品牌识别、登录页、欢迎页、SEO 信息、页尾与备案、皮肤主题。切换 Tab 保留页面草稿与待上传文件;统一保存校验失败时切回问题字段所在分类。欢迎页开关沿用独立接口,切换后立即保存,其余分类共用保存、撤销和未保存离开保护。主题编辑器由主题库、身份编辑、浅/深模式切换、实时控件预览、语义颜色分组和组件参数分组组成;默认主题展示锁定原因,自定义主题显示复制与删除动作。 + +用户工作台和后台的主按钮通过公共 Ant Design 主题及 `primary-buttons.css` 适配器消费皮肤参数,创作页复合按钮使用相同的 `--button-primary-*` 填充变量;页面不再硬编码渐变颜色。危险、幽灵和禁用按钮不套用渐变。后台路由不挂载用户工作台外壳和浮层样式;后台保留独立表面与控件密度。输入、数字输入、选择和日期控件关闭焦点阴影,按钮关闭点击波纹;键盘导航仍保留可见焦点,校验错误状态保留。 所有调整先形成页面草稿;只有点击“保存修改”才上传新资源并提交配置。保存响应成为新基线。离开、刷新覆盖和恢复内置品牌需要沿用现有未保存保护;恢复操作不删除已经上传的存储资源。皮肤卡必须可键盘操作,并以 radio/`aria-checked` 语义暴露选中状态。原生取色器只在点击颜色方块本身时打开;点击字段名称、说明或 HEX 输入不会代理触发系统取色器。取色器与 HEX 输入共享同一字段,非法中间值允许留在草稿中便于继续编辑,但前端保存校验和后端权威校验都会阻止其生效。 diff --git a/web/src/components/layout/app-providers.tsx b/web/src/components/layout/app-providers.tsx index 5a1977ef4..827a0c551 100644 --- a/web/src/components/layout/app-providers.tsx +++ b/web/src/components/layout/app-providers.tsx @@ -39,7 +39,7 @@ export function AppProviders({ children }: { children: ReactNode }) { const isolateDevRepro = import.meta.env.DEV && typeof window !== "undefined" && window.location.pathname === "/dev/director-repro"; return ( - + {isolateDevRepro ? ( diff --git a/web/src/layouts/user-layout.tsx b/web/src/layouts/user-layout.tsx index ed8c457d2..4bc3469b6 100644 --- a/web/src/layouts/user-layout.tsx +++ b/web/src/layouts/user-layout.tsx @@ -13,13 +13,14 @@ const workspaceTheme = getWorkspaceAntThemeConfig(); export default function UserLayout({ children }: { children: ReactNode }) { const { pathname } = useLocation(); + const adminWorkspace = pathname === "/admin" || pathname.startsWith("/admin/"); const spatialWorkbench = isSpatialWorkbenchPath(pathname); - const productWorkspace = !/^\/canvas\/[^/]+(?:\/|$)/.test(pathname); + const productWorkspace = !adminWorkspace && !/^\/canvas\/[^/]+(?:\/|$)/.test(pathname); useWorkspaceButtonFeedback(productWorkspace); useLayoutEffect(() => { // Ant Design 浮层挂载在 body,必须用路由级标记隔离用户工作台与画布编辑器、运营后台。 - document.body.classList.add("app-user-overlays"); + document.body.classList.toggle("app-user-overlays", !adminWorkspace); document.body.classList.toggle("app-spatial-overlays", spatialWorkbench); document.body.classList.toggle("app-product-overlays", productWorkspace); return () => { @@ -27,7 +28,9 @@ export default function UserLayout({ children }: { children: ReactNode }) { document.body.classList.remove("app-spatial-overlays"); document.body.classList.remove("app-product-overlays"); }; - }, [spatialWorkbench, productWorkspace]); + }, [adminWorkspace, spatialWorkbench, productWorkspace]); + + if (adminWorkspace) return children; return ( diff --git a/web/src/lib/app-theme.ts b/web/src/lib/app-theme.ts index 219a2315b..d3a0e7155 100644 --- a/web/src/lib/app-theme.ts +++ b/web/src/lib/app-theme.ts @@ -164,6 +164,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T colorLinkHover: color.solidHoverBg, colorLinkActive: color.solidActiveBg, colorTextLightSolid: color.solidFg, + controlOutlineWidth: 0, colorText: skin.text || undefined, colorTextSecondary: skin.textMuted || undefined, colorIcon: skin.icon || undefined, @@ -196,6 +197,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T }, components: { Button: { + defaultShadow: "none", primaryShadow: "none", dangerShadow: "none", dangerColor: color.dangerForeground, @@ -235,6 +237,11 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T hoverBorderColor: interactiveBorder, activeShadow: focusShadow, }, + DatePicker: { + activeShadow: focusShadow, + activeBorderColor: interactiveBorder, + hoverBorderColor: interactiveBorder, + }, Switch: { handleBg: color.switchOffHandle, handleShadow: dark ? "0 1px 4px rgba(0, 0, 0, 0.42)" : "0 1px 2px rgba(0, 0, 0, 0.2)", @@ -394,7 +401,7 @@ export function getWorkspaceAntThemeConfig(): ThemeConfig { defaultShadow: "none", primaryShadow: "none", dangerShadow: "none", paddingInline: 14, paddingInlineSM: 10, }, - Input: { borderRadius: 12, activeShadow: "0 0 0 2px var(--user-accent-soft)", paddingInline: 12 }, + Input: { borderRadius: 12, paddingInline: 12 }, Select: { borderRadius: 12, optionSelectedBg: "var(--user-accent-soft)", optionSelectedColor: "var(--user-ink)", optionActiveBg: "var(--user-surface-muted)", optionPadding: "9px 12px" }, Dropdown: { borderRadiusLG: 16, controlItemBgHover: "var(--user-surface-muted)", paddingBlock: 6 }, Modal: { borderRadiusLG: 20 }, diff --git a/web/src/lib/skin-themes.ts b/web/src/lib/skin-themes.ts index 11babf1d6..d08e62eb0 100644 --- a/web/src/lib/skin-themes.ts +++ b/web/src/lib/skin-themes.ts @@ -1,6 +1,32 @@ export type SkinThemeMode = "light" | "dark"; export type SkinShadowStyle = "none" | "soft" | "strong"; +export type SkinButtonFill = { + mode: "solid" | "gradient"; + angle: number; + start: string; + end: string; + hoverStart: string; + hoverEnd: string; + activeStart: string; + activeEnd: string; + foreground: string; +}; + +export const DEFAULT_BUTTON_GRADIENT: SkinButtonFill = { + mode: "gradient", angle: 115, + start: "#6554df", end: "#386fbc", + hoverStart: "#5744cf", hoverEnd: "#356bbb", + activeStart: "#4938b8", activeEnd: "#2c5da5", foreground: "#ffffff", +}; + +export const SKIN_BUTTON_COLOR_FIELDS = [ + { key: "start", label: "渐变起色" }, { key: "end", label: "渐变终色" }, + { key: "hoverStart", label: "悬停起色" }, { key: "hoverEnd", label: "悬停终色" }, + { key: "activeStart", label: "按下起色" }, { key: "activeEnd", label: "按下终色" }, + { key: "foreground", label: "渐变按钮文字" }, +] as const; + export type SkinModeTokens = { canvas: string; surface: string; @@ -78,6 +104,7 @@ export type SkinTokens = { light: SkinModeTokens; dark: SkinModeTokens; components: SkinComponentTokens; + buttons: Record; }; export type SkinDefinition = { @@ -197,7 +224,7 @@ export const SKIN_COLOR_GROUPS: readonly SkinColorGroup[] = [ { key: "switchUnchecked", label: "开关关闭", help: "Switch 关闭轨道" }, { key: "switchUncheckedHover", label: "关闭悬停", help: "关闭状态 hover 轨道" }, { key: "switchUncheckedHandle", label: "关闭滑块", help: "关闭状态圆形滑块" }, - { key: "primary", label: "主操作", help: "主要按钮、链接和开关" }, + { key: "primary", label: "主操作", help: "纯色主按钮与链接;开关使用独立颜色" }, { key: "primaryHover", label: "主操作悬停", help: "主要操作 hover" }, { key: "primaryActive", label: "主操作按下", help: "主要操作 active" }, { key: "primaryForeground", label: "主操作前景", help: "主按钮上的文字和图标" }, @@ -371,6 +398,7 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = { authAccent: "#93c5fd", authMuted: "#8a8b91", }, + buttons: { light: { ...DEFAULT_BUTTON_GRADIENT }, dark: { ...DEFAULT_BUTTON_GRADIENT } }, components: { buttonRadius: 6, inputRadius: 6, @@ -395,6 +423,7 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = { const HEX_COLOR = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i; const MANAGED_VARIABLES = [ + "--button-primary-bg", "--button-primary-hover-bg", "--button-primary-active-bg", "--button-primary-fg", "--background", "--foreground", "--card", @@ -504,7 +533,10 @@ export function normalizeSkinDefinition(value: unknown, fallback: SkinDefinition name: typeof candidate.name === "string" && candidate.name.trim() ? candidate.name.trim().slice(0, 40) : fallback.name, description: typeof candidate.description === "string" ? candidate.description.trim().slice(0, 100) : fallback.description, locked: id === "classic", - tokens: { light, dark, components: { ...modes.components } }, + tokens: { light, dark, components: { ...modes.components }, buttons: { + light: normalizeButtonFill(modes.buttons?.light, id), + dark: normalizeButtonFill(modes.buttons?.dark, id), + } }, }; } @@ -662,9 +694,37 @@ export function applySkinTheme(skinValue: unknown, mode: SkinThemeMode, targetDo const values = skinCSSVariables(skin, mode); for (const [property, value] of Object.entries(values)) root.style.setProperty(property, value); } + for (const [property, value] of Object.entries(skinButtonCSSVariables(skin, mode))) root.style.setProperty(property, value); root.dataset.skin = skin.id; } +export function isSkinButtonFill(value: unknown): value is SkinButtonFill { + if (!value || typeof value !== "object") return false; + const fill = value as SkinButtonFill; + return ["solid", "gradient"].includes(fill.mode) && Number.isInteger(fill.angle) && fill.angle >= 0 && fill.angle <= 360 + && SKIN_BUTTON_COLOR_FIELDS.every(({ key }) => typeof fill[key] === "string" && HEX_COLOR.test(fill[key])); +} + +function normalizeButtonFill(value: unknown, skinID: string): SkinButtonFill { + return isSkinButtonFill(value) ? { ...value } : { ...DEFAULT_BUTTON_GRADIENT, mode: skinID === "classic" ? "gradient" : "solid" }; +} + +export function getSkinButtonAppearance(skin: SkinDefinition, mode: SkinThemeMode) { + const fill = normalizeButtonFill(skin.tokens.buttons?.[mode], skin.id); + const color = skin.tokens[mode]; + const gradient = (start: string, end: string) => `linear-gradient(${fill.angle}deg, ${start}, ${end})`; + return fill.mode === "gradient" ? { + background: gradient(fill.start, fill.end), hover: gradient(fill.hoverStart, fill.hoverEnd), + active: gradient(fill.activeStart, fill.activeEnd), foreground: fill.foreground, + } : { background: color.primary, hover: color.primaryHover, active: color.primaryActive, foreground: color.primaryForeground }; +} + +function skinButtonCSSVariables(skin: SkinDefinition, mode: SkinThemeMode) { + const button = getSkinButtonAppearance(skin, mode); + return { "--button-primary-bg": button.background, "--button-primary-hover-bg": button.hover, + "--button-primary-active-bg": button.active, "--button-primary-fg": button.foreground }; +} + export function skinCSSVariables(skin: SkinDefinition, mode: SkinThemeMode): Record { const color = skin.tokens[mode]; const component = skin.tokens.components; @@ -777,7 +837,8 @@ function skinShadowVariables(style: SkinShadowStyle, mode: SkinThemeMode) { } function cloneSkinTokens(tokens: SkinTokens): SkinTokens { - return { light: { ...tokens.light }, dark: { ...tokens.dark }, components: { ...tokens.components } }; + return { light: { ...tokens.light }, dark: { ...tokens.dark }, components: { ...tokens.components }, + buttons: { light: { ...tokens.buttons.light }, dark: { ...tokens.buttons.dark } } }; } function isSkinMode(value: unknown): value is SkinModeTokens { diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 8ef978d80..6c91c56d4 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -139,7 +139,7 @@ export function AdminShell() { }; return ( - adminPopupContainer(node)}> + adminPopupContainer(node)}>