From 91debb9ad679ca7f41e5444152df20d65a0c0a2a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 9 Jul 2026 00:12:30 +0800 Subject: [PATCH] feat: add settings schema --- README.md | 2 +- docs/00-ai-start-here.md | 2 +- docs/03-tech-stack.md | 1 + docs/04-architecture.md | 13 +- docs/06-tasks.md | 2 +- docs/api.md | 6 +- docs/current-state.md | 17 +- docs/method-map.md | 5 +- docs/quality-document.md | 4 +- docs/routes.md | 4 +- .../bindings/cmbone/internal/models/models.js | 182 ++++++++++++++ .../internal/services/appconfigservice.js | 20 ++ .../src/components/Setting/ThemeSetting.vue | 15 +- frontend/src/locales/en.json | 3 +- frontend/src/locales/zh-HK.json | 3 +- frontend/src/locales/zh.json | 5 +- frontend/src/main.ts | 12 +- .../src/modules/settings/SettingsPage.vue | 136 ++++++++++- internal/models/settings.go | 25 ++ internal/services/appconfig_service.go | 106 +++++++- internal/services/settings_schema.go | 226 ++++++++++++++++++ internal/services/store_test.go | 86 +++++++ internal/services/systeminfo.go | 18 -- progress.md | 27 +++ 24 files changed, 867 insertions(+), 53 deletions(-) create mode 100644 internal/models/settings.go create mode 100644 internal/services/settings_schema.go diff --git a/README.md b/README.md index eb0497c..1becaa7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - 后端:Go 1.23.0、Wails v3.0.0-alpha.9、SQLite - 前端:Vue 3、TypeScript、Vite、Tailwind CSS、Ant Design Vue -- 当前功能:多语言、主题切换、本地配置、快捷键、本地登录 provider、OCR 示例 +- 当前功能:多语言、主题切换、统一设置 schema、本地配置、快捷键、本地登录 provider、OCR 示例 - 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例 ## 环境准备 diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 26f9eef..eb32d8a 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -34,7 +34,7 @@ ## 当前阶段 -当前项目处于:可复用桌面应用模板的产品定位校准阶段。 +当前项目处于:可复用桌面应用模板的 Phase 1 基础模块建设阶段。 优先路径: diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 08f7a5f..f861e4c 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -134,6 +134,7 @@ Windows build 资源: ## 目标模块技术边界 +- 设置:通过 `settings_schema.go` 集中定义 key、类型、默认值、说明、选项和只读策略。 - 登录:通过 `AuthProvider` 支持本地固定账号和真实后端,不在前端写死密码。 - 主业务:商品图片 AI 优化通过服务层封装,MVP 可先用本地模拟 provider。 - 操作日志:审计日志进 SQLite,运行日志可进 SQLite 或本地滚动日志文件。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index ec77baf..9352538 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -38,6 +38,7 @@ frontend/src - `internal/services/store.go`:负责 SQLite 连接、迁移和基础读写。 - `internal/services/appservice.go`:负责窗口、托盘、语言资源和应用生命周期。 - `internal/services/appconfig_service.go`:负责配置读写的 Wails 服务边界。 +- `internal/services/settings_schema.go`:集中定义设置模块 schema、默认值、选项和初始化 SQL。 - `internal/services/hotkey_service.go`:负责热键读取、更新和重新注册。 - `internal/services/ocr_service.go`:当前 OCR 存量示例,后续可演进为图片 AI 能力的一部分。 - `platform/`:隔离 Windows / macOS 热键和系统能力差异。 @@ -45,6 +46,7 @@ frontend/src 已实现边界: - `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。当前 `LocalAuthProvider` 可用,`RemoteAuthProvider` 保留边界并返回未配置错误。 +- `AppConfigService`:通过统一设置 schema 初始化和校验主题、语言、窗口行为、快捷键展示、数据目录、日志策略和登录配置。 目标新增边界: @@ -123,6 +125,15 @@ appconfig( ) ``` +当前 `appconfig` 默认配置由统一设置 schema 生成: + +- `theme.mode`、`language` +- `window.start_state`、`window.width`、`window.height`、`window.remember_state` +- `shortcut.open_second_window`、`shortcut.open_main_window` +- `data.root_mode`、`data.custom_directory` +- `logs.level`、`logs.audit_retention_days`、`logs.app_retention_days` +- `auth.provider`、`auth.local.username`、`auth.local.password_salt`、`auth.local.password_hash` + ```sql auth_sessions( id INTEGER PRIMARY KEY, @@ -213,7 +224,7 @@ image_task_results( - 第二窗口保持当前小窗定位,不参与最大化、尺寸恢复和主布局填充逻辑。 - 后续增强再保存主窗口尺寸和最大化状态,下次启动恢复用户选择。 -实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地;登录 provider 边界已落地。窗口尺寸持久化依赖设置 schema 后续实现。 +实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地;登录 provider 边界已落地;设置模块统一 schema 已落地。窗口尺寸持久化下一步基于 `window.*` 配置实现。 ## 关键风险 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index bd20588..eee3a96 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -30,7 +30,7 @@ | T-011 | 实现主窗口启动参数与根布局策略 | T-010 | Go 侧设置主窗口初始宽高、居中、可调整大小;前端根容器和主布局填满窗口;阅读型内容有最大宽度,列表/仪表盘/设置页可自适应拉伸 | DONE | | T-012 | 搭建主应用路由骨架 | T-011 | `login`、`dashboard`、`image-optimizer`、`logs`、`statistics`、`settings`、`components` 路由可访问 | DONE | | T-013 | 实现登录 provider 边界 | T-012 | 支持本地固定账号 provider;预留 remote provider;账号密码不写死在前端;登录事件写审计日志 | DONE | -| T-014 | 收敛设置模块 schema | T-012 | 主题、语言、窗口行为、快捷键、数据目录、日志策略都有统一配置定义和默认值 | TODO | +| T-014 | 收敛设置模块 schema | T-012 | 主题、语言、窗口行为、快捷键、数据目录、日志策略都有统一配置定义和默认值 | DONE | | T-015 | 持久化窗口尺寸和最大化状态 | T-014 | 保存主窗口尺寸和最大化状态;下次启动恢复用户上次选择;第二窗口不参与该逻辑 | TODO | ## Phase 2 · 业务样例与日志统计 diff --git a/docs/api.md b/docs/api.md index 361f151..130e06c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,11 +24,15 @@ - `SetAppConfig(key string, value string) error`:保存配置值。 - `GetLanguage() string`:读取当前语言。 - `SetLanguage(lang string) error`:保存当前语言。 +- `GetSettingsSchema() ([]models.AppSettingGroup, error)`:返回统一设置 schema、默认值和当前值。 约束: - 配置持久化走 SQLite `appconfig` 表。 -- 新增配置项时,必须有默认值和迁移逻辑。 +- 统一设置 schema 定义在 `internal/services/settings_schema.go`,覆盖主题、语言、窗口行为、快捷键、数据目录、日志策略和登录 provider 配置。 +- 新增配置项时,必须补充 key、类型、默认值、说明和必要选项,并同步初始化 SQL。 +- `SetAppConfig` 会校验 schema 已知配置项;只读项不能通过通用配置接口修改。 +- 快捷键 schema 项只用于统一展示,实际热键更新仍走 `HotkeyService`。 ### HotkeyService diff --git a/docs/current-state.md b/docs/current-state.md index 01f20b7..0d2284e 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,9 +4,9 @@ ## 当前阶段 -项目处于可复用桌面应用模板的产品定位校准阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。 +项目处于可复用桌面应用模板的 Phase 1 基础模块建设阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。 -当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架和登录 provider 边界阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口和本地登录能力。商品图片 AI 优化、完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。 +当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界和统一设置 schema 阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口和本地登录能力。商品图片 AI 优化、完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。 ## 已确认事实 @@ -35,6 +35,8 @@ - 已保留第二窗口策略:保持小窗,不参与主窗口最大化和尺寸恢复逻辑。 - 已实现 AuthService:`GetAuthProviders`、`GetAuthMode`、`SetAuthMode`、`Login`、`Logout`、`GetCurrentSession`。 - 已实现登录审计留痕:登录成功、登录失败和退出写入 `audit_logs`。 +- 已实现统一设置 schema:主题、语言、窗口行为、快捷键展示、数据目录、日志策略和登录 provider 配置都有默认值、说明和基础校验。 +- 已实现设置页 schema 展示:`/#/settings` 可读取后端 `GetSettingsSchema` 并展示分组、当前值、默认值、说明和只读标记。 ## 当前命令 @@ -136,9 +138,18 @@ wails3 build - 登录成功、失败和退出会写审计日志。 - `go test ./...`、`npm run typecheck`、`npm run build`、`npm run build:dev`、`go build -o bin\cmbone.exe .`、`cmd /c build.bat` 和 `dev.bat` 烟测通过。 +同日完成统一设置 schema: + +- 新增 `internal/services/settings_schema.go` 和 `internal/models/settings.go`。 +- `AppConfigService.GetSettingsSchema` 返回设置分组、配置项、默认值和当前值。 +- `SetAppConfig` 对 schema 已知项执行 select、boolean、number 和只读校验。 +- `theme.mode` 改为从后端配置读取,并支持 `light`、`dark`、`systemdefault`。 +- `/#/settings` 展示统一设置 schema。 +- `go test ./...`、`npm run typecheck`、`npm run build`、`go build -o bin\cmbone.exe .`、`cmd /c build.bat` 通过。 + ## 下一步 -从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-014`:收敛设置模块 schema。 +从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-015`:持久化窗口尺寸和最大化状态。 ## 当前 blocker diff --git a/docs/method-map.md b/docs/method-map.md index 14d5e3d..7de338c 100644 --- a/docs/method-map.md +++ b/docs/method-map.md @@ -23,6 +23,9 @@ | --- | --- | --- | | `internal/services/appconfig_service.go` | `GetAppConfig` / `SetAppConfig` | 前端配置读写入口 | | `internal/services/appconfig_service.go` | `GetLanguage` / `SetLanguage` | 语言配置读写入口 | +| `internal/services/appconfig_service.go` | `GetSettingsSchema` | 返回设置分组、默认值和当前值 | +| `internal/services/settings_schema.go` | settings schema | 集中定义主题、语言、窗口、快捷键、数据目录、日志策略和登录配置 | +| `internal/models/settings.go` | `AppSettingGroup` / `AppSettingDefinition` | 设置 schema 前端绑定模型 | ## 登录 @@ -66,7 +69,7 @@ | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主业务骨架 | 商品图片 AI 优化任务入口 | | `frontend/src/modules/logs/LogsPage.vue` | 日志页骨架 | 审计日志和运行日志入口 | | `frontend/src/modules/statistics/StatisticsPage.vue` | 统计页骨架 | P0 指标和 7 天趋势入口 | -| `frontend/src/modules/settings/SettingsPage.vue` | 设置页骨架 | 复用存量主题、语言和快捷键设置 | +| `frontend/src/modules/settings/SettingsPage.vue` | 设置页 | 复用存量设置组件,并展示后端统一设置 schema | | `frontend/src/modules/components-demo/ComponentsDemoPage.vue` | 组件示例骨架 | 常用表单、筛选、表格和状态标签 | | `frontend/src/pages/Home/Index.vue` | 第二窗口入口 | OCR 示例页面 | | `frontend/src/utils/ThemeManager.ts` | 主题工具 | 主题切换和持久化协作 | diff --git a/docs/quality-document.md b/docs/quality-document.md index aeed211..dadc0e1 100644 --- a/docs/quality-document.md +++ b/docs/quality-document.md @@ -6,9 +6,9 @@ | --- | --- | --- | | 项目结构 | B | 已从模板整理为普通工程,目录职责基本清楚 | | 产品定位 | B | 已校准为 Windows-first 可复用桌面应用模板,后续需要代码落地 | -| 后端服务 | B | 现有服务边界清晰,登录、日志、统计和业务样例服务尚未实现 | +| 后端服务 | B+ | 现有服务边界清晰,登录 provider 和设置 schema 已落地,日志、统计和业务样例服务尚未实现 | | 本地存储 | B | SQLite 初始化和基础测试已存在,目标新增表需要迁移和测试 | -| 前端结构 | B | Vue 组件按功能拆分,窗口和布局策略已落地,目标路由骨架尚未实现 | +| 前端结构 | B+ | Vue 组件按功能拆分,窗口、布局策略和目标路由骨架已落地,设置页已接入后端 schema | | 构建验证 | B | Go 测试、前端构建、后端编译可运行 | | 文档 | B+ | Harness Coding 文档已接入,并同步了模板愿景 | diff --git a/docs/routes.md b/docs/routes.md index 5e1c285..85e9f92 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,7 +14,7 @@ | `#/image-optimizer` | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主窗口 | 商品图片 AI 优化业务样例骨架 | | `#/logs` | `frontend/src/modules/logs/LogsPage.vue` | 主窗口 | 审计日志和运行日志统一入口骨架 | | `#/statistics` | `frontend/src/modules/statistics/StatisticsPage.vue` | 主窗口 | 第一批统计指标和趋势骨架 | -| `#/settings` | `frontend/src/modules/settings/SettingsPage.vue` | 主窗口 | 设置中心,当前复用存量主题、语言和快捷键设置 | +| `#/settings` | `frontend/src/modules/settings/SettingsPage.vue` | 主窗口 | 设置中心,复用存量设置组件并展示统一设置 schema | | `#/components` | `frontend/src/modules/components-demo/ComponentsDemoPage.vue` | 主窗口 | 常用输入、选择、文本域、表格、筛选和状态标签示例 | | `#/second` | `frontend/src/pages/Home/Index.vue` | 第二窗口 | 当前 OCR 示例和轻量快捷功能 | | `#/business/image-optimizer` | redirect | 主窗口 | 兼容目标路由,重定向到 `#/image-optimizer` | @@ -29,7 +29,7 @@ | `#/image-optimizer` | 商品图片 AI 优化 | `T-020` / `T-023` 接入任务模型、任务列表、状态、结果和失败原因 | | `#/logs` | 操作日志 | `T-021` 接入审计日志和运行日志查询 | | `#/statistics` | 数据统计 | `T-022` 接入趋势、成功率、耗时和失败任务 | -| `#/settings` | 设置中心 | `T-014` 接入统一设置 schema | +| `#/settings` | 设置中心 | 已接入统一设置 schema,`T-015` 继续使用窗口配置做尺寸恢复 | | `#/components` | 组件示例 | `T-030` 扩充输入框、多选框、下拉框、多行文本、表格、筛选、分页 | ## 主窗口布局 diff --git a/frontend/bindings/cmbone/internal/models/models.js b/frontend/bindings/cmbone/internal/models/models.js index 58bd29a..7c0fded 100644 --- a/frontend/bindings/cmbone/internal/models/models.js +++ b/frontend/bindings/cmbone/internal/models/models.js @@ -6,6 +6,182 @@ // @ts-ignore: Unused imports import {Create as $Create} from "@wailsio/runtime"; +export class AppSettingDefinition { + /** + * Creates a new AppSettingDefinition instance. + * @param {Partial} [$$source = {}] - The source object to create the AppSettingDefinition. + */ + constructor($$source = {}) { + if (!("key" in $$source)) { + /** + * @member + * @type {string} + */ + this["key"] = ""; + } + if (!("category" in $$source)) { + /** + * @member + * @type {string} + */ + this["category"] = ""; + } + if (!("label" in $$source)) { + /** + * @member + * @type {string} + */ + this["label"] = ""; + } + if (!("type" in $$source)) { + /** + * @member + * @type {string} + */ + this["type"] = ""; + } + if (!("defaultValue" in $$source)) { + /** + * @member + * @type {string} + */ + this["defaultValue"] = ""; + } + if (!("value" in $$source)) { + /** + * @member + * @type {string} + */ + this["value"] = ""; + } + if (!("description" in $$source)) { + /** + * @member + * @type {string} + */ + this["description"] = ""; + } + if (!("options" in $$source)) { + /** + * @member + * @type {AppSettingOption[]} + */ + this["options"] = []; + } + if (!("readOnly" in $$source)) { + /** + * @member + * @type {boolean} + */ + this["readOnly"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AppSettingDefinition instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AppSettingDefinition} + */ + static createFrom($$source = {}) { + const $$createField7_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("options" in $$parsedSource) { + $$parsedSource["options"] = $$createField7_0($$parsedSource["options"]); + } + return new AppSettingDefinition(/** @type {Partial} */($$parsedSource)); + } +} + +export class AppSettingGroup { + /** + * Creates a new AppSettingGroup instance. + * @param {Partial} [$$source = {}] - The source object to create the AppSettingGroup. + */ + constructor($$source = {}) { + if (!("category" in $$source)) { + /** + * @member + * @type {string} + */ + this["category"] = ""; + } + if (!("label" in $$source)) { + /** + * @member + * @type {string} + */ + this["label"] = ""; + } + if (!("description" in $$source)) { + /** + * @member + * @type {string} + */ + this["description"] = ""; + } + if (!("items" in $$source)) { + /** + * @member + * @type {AppSettingDefinition[]} + */ + this["items"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AppSettingGroup instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AppSettingGroup} + */ + static createFrom($$source = {}) { + const $$createField3_0 = $$createType3; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("items" in $$parsedSource) { + $$parsedSource["items"] = $$createField3_0($$parsedSource["items"]); + } + return new AppSettingGroup(/** @type {Partial} */($$parsedSource)); + } +} + +export class AppSettingOption { + /** + * Creates a new AppSettingOption instance. + * @param {Partial} [$$source = {}] - The source object to create the AppSettingOption. + */ + constructor($$source = {}) { + if (!("value" in $$source)) { + /** + * @member + * @type {string} + */ + this["value"] = ""; + } + if (!("label" in $$source)) { + /** + * @member + * @type {string} + */ + this["label"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AppSettingOption instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AppSettingOption} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new AppSettingOption(/** @type {Partial} */($$parsedSource)); + } +} + export class AuthProviderOption { /** * Creates a new AuthProviderOption instance. @@ -176,3 +352,9 @@ export class Hotkey { return new Hotkey(/** @type {Partial} */($$parsedSource)); } } + +// Private type creation functions +const $$createType0 = AppSettingOption.createFrom; +const $$createType1 = $Create.Array($$createType0); +const $$createType2 = AppSettingDefinition.createFrom; +const $$createType3 = $Create.Array($$createType2); diff --git a/frontend/bindings/cmbone/internal/services/appconfigservice.js b/frontend/bindings/cmbone/internal/services/appconfigservice.js index f80c987..58cb022 100644 --- a/frontend/bindings/cmbone/internal/services/appconfigservice.js +++ b/frontend/bindings/cmbone/internal/services/appconfigservice.js @@ -6,6 +6,10 @@ // @ts-ignore: Unused imports import {Call as $Call, Create as $Create} from "@wailsio/runtime"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as models$0 from "../models/models.js"; + /** * 根据key获取配置项 * @param {string} key @@ -25,6 +29,18 @@ export function GetLanguage() { return $resultPromise; } +/** + * @returns {Promise & { cancel(): void }} + */ +export function GetSettingsSchema() { + let $resultPromise = /** @type {any} */($Call.ByID(2109450092)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType1($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + /** * 设置或更新配置项 * @param {string} key @@ -45,3 +61,7 @@ export function SetLanguage(lang) { let $resultPromise = /** @type {any} */($Call.ByID(538150432, lang)); return $resultPromise; } + +// Private type creation functions +const $$createType0 = models$0.AppSettingGroup.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/frontend/src/components/Setting/ThemeSetting.vue b/frontend/src/components/Setting/ThemeSetting.vue index 56276b5..d92569a 100644 --- a/frontend/src/components/Setting/ThemeSetting.vue +++ b/frontend/src/components/Setting/ThemeSetting.vue @@ -1,18 +1,22 @@ @@ -25,6 +29,7 @@ onMounted(() => { > {{$t('components.themesetting.select.opt_light')}} {{$t('components.themesetting.select.opt_dark')}} + {{$t('components.themesetting.select.opt_system')}} - \ No newline at end of file + diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 014ad19..d5cd7d1 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -51,7 +51,8 @@ "themesetting": { "select": { "opt_dark": "Dark", - "opt_light": "Bright" + "opt_light": "Bright", + "opt_system": "System" } }, "about": { diff --git a/frontend/src/locales/zh-HK.json b/frontend/src/locales/zh-HK.json index ec1299d..c795b49 100644 --- a/frontend/src/locales/zh-HK.json +++ b/frontend/src/locales/zh-HK.json @@ -53,7 +53,8 @@ "themesetting": { "select": { "opt_dark": "暗色模式", - "opt_light": "亮色模式" + "opt_light": "亮色模式", + "opt_system": "跟隨系統" } }, "about": { diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 8fa5b08..b5dcc9d 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -52,7 +52,8 @@ "themesetting":{ "select":{ "opt_light":"亮色模式", - "opt_dark":"暗色模式" + "opt_dark":"暗色模式", + "opt_system":"跟随系统" } }, "about": { @@ -96,4 +97,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 237470a..7d30fa2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,16 +4,22 @@ import App from './App.vue' import './style.css' import 'ant-design-vue/dist/reset.css'; import {i18n,setupI18n } from './utils/i18n' -import { initTheme } from './utils/ThemeManager'//add +import { initTheme, setTheme, type ThemeMode } from './utils/ThemeManager'//add import router from './router/index' -import { GetLanguage } from '../bindings/cmbone/internal/services/appconfigservice' +import { GetAppConfig, GetLanguage } from '../bindings/cmbone/internal/services/appconfigservice' initTheme() async function bootstrap(){ await setupI18n('zh')//默认语言或从后端读取 createApp(App).use(Antd).use(router).use(i18n).mount('#app') router.isReady().then(async () => { - const lang = await GetLanguage() //从后端读取语言设置 lang as any|| 'zh' + const [lang, themeMode] = await Promise.all([ + GetLanguage(), + GetAppConfig('theme.mode'), + ]) + if (themeMode) { + setTheme(themeMode as ThemeMode) + } await setupI18n(lang as any|| 'zh') }) } diff --git a/frontend/src/modules/settings/SettingsPage.vue b/frontend/src/modules/settings/SettingsPage.vue index 686e991..d79b0e4 100644 --- a/frontend/src/modules/settings/SettingsPage.vue +++ b/frontend/src/modules/settings/SettingsPage.vue @@ -1,13 +1,71 @@ diff --git a/internal/models/settings.go b/internal/models/settings.go new file mode 100644 index 0000000..2678975 --- /dev/null +++ b/internal/models/settings.go @@ -0,0 +1,25 @@ +package models + +type AppSettingOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type AppSettingDefinition struct { + Key string `json:"key"` + Category string `json:"category"` + Label string `json:"label"` + Type string `json:"type"` + DefaultValue string `json:"defaultValue"` + Value string `json:"value"` + Description string `json:"description"` + Options []AppSettingOption `json:"options"` + ReadOnly bool `json:"readOnly"` +} + +type AppSettingGroup struct { + Category string `json:"category"` + Label string `json:"label"` + Description string `json:"description"` + Items []AppSettingDefinition `json:"items"` +} diff --git a/internal/services/appconfig_service.go b/internal/services/appconfig_service.go index c3a2f20..a94fe2a 100644 --- a/internal/services/appconfig_service.go +++ b/internal/services/appconfig_service.go @@ -1,5 +1,12 @@ package services +import ( + "cmbone/internal/models" + "database/sql" + "fmt" + "strconv" +) + type AppConfigService struct { store *SuiStore } @@ -22,18 +29,36 @@ func (r *AppConfigService) GetAppConfig(key string) (string, error) { var value string err := row.Scan(&value) if err != nil { - return "en", err + if err == sql.ErrNoRows { + return "", nil + } + return "", err } return value, nil } // 设置或更新配置项 func (r *AppConfigService) SetAppConfig(key, value string) error { + configType := "user" + description := "" + if definition, ok := settingDefinitionByKey(key); ok { + if definition.ReadOnly { + return fmt.Errorf("setting %q is read only", key) + } + if err := validateSettingValue(definition, value); err != nil { + return err + } + configType = definition.Type + description = definition.Description + } _, err := r.store.DB.Exec(` INSERT INTO appconfig (key, type, value, description) - VALUES (?, 'user', ?, '') - ON CONFLICT(key) DO UPDATE SET value=excluded.value - `, key, value) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + type=excluded.type, + value=excluded.value, + description=excluded.description + `, key, configType, value, description) return err } @@ -50,3 +75,76 @@ func (s *AppConfigService) GetLanguage() string { func (s *AppConfigService) SetLanguage(lang string) error { return s.SetAppConfig("language", lang) } + +func (s *AppConfigService) GetSettingsSchema() ([]models.AppSettingGroup, error) { + values, err := s.getAppConfigValues() + if err != nil { + return nil, err + } + + definitionsByCategory := make(map[string][]models.AppSettingDefinition) + for _, definition := range settingsDefinitions() { + value := values[definition.Key] + if value == "" { + value = definition.DefaultValue + } + definition.Value = value + if definition.Options == nil { + definition.Options = []models.AppSettingOption{} + } + definitionsByCategory[definition.Category] = append(definitionsByCategory[definition.Category], definition) + } + + groups := make([]models.AppSettingGroup, 0, len(settingGroups)) + for _, group := range settingGroups { + group.Items = definitionsByCategory[group.Category] + if len(group.Items) > 0 { + groups = append(groups, group) + } + } + return groups, nil +} + +func (s *AppConfigService) getAppConfigValues() (map[string]string, error) { + rows, err := s.store.DB.Query("SELECT key, value FROM appconfig") + if err != nil { + return nil, err + } + defer rows.Close() + + values := make(map[string]string) + for rows.Next() { + var key string + var value string + if err := rows.Scan(&key, &value); err != nil { + return nil, err + } + values[key] = value + } + if err := rows.Err(); err != nil { + return nil, err + } + return values, nil +} + +func validateSettingValue(definition models.AppSettingDefinition, value string) error { + switch definition.Type { + case settingTypeSelect: + for _, option := range definition.Options { + if option.Value == value { + return nil + } + } + return fmt.Errorf("setting %q value %q is not allowed", definition.Key, value) + case settingTypeBoolean: + if value == "true" || value == "false" { + return nil + } + return fmt.Errorf("setting %q expects boolean string", definition.Key) + case settingTypeNumber: + if _, err := strconv.Atoi(value); err != nil { + return fmt.Errorf("setting %q expects number string: %w", definition.Key, err) + } + } + return nil +} diff --git a/internal/services/settings_schema.go b/internal/services/settings_schema.go new file mode 100644 index 0000000..b9a943e --- /dev/null +++ b/internal/services/settings_schema.go @@ -0,0 +1,226 @@ +package services + +import ( + "cmbone/internal/models" + "fmt" + "strings" +) + +const ( + settingTypeSelect = "select" + settingTypeBoolean = "boolean" + settingTypeNumber = "number" + settingTypeText = "text" + settingTypePath = "path" + settingTypeHotkey = "hotkey" +) + +var settingGroups = []models.AppSettingGroup{ + {Category: "appearance", Label: "外观与语言", Description: "主题和界面语言设置"}, + {Category: "window", Label: "窗口行为", Description: "主窗口默认尺寸、启动状态和恢复策略"}, + {Category: "shortcuts", Label: "快捷键", Description: "全局快捷键默认值和入口说明"}, + {Category: "data", Label: "数据目录", Description: "本地数据库、输出文件和缓存目录策略"}, + {Category: "logs", Label: "日志策略", Description: "审计日志和运行日志的级别与保留周期"}, + {Category: "auth", Label: "登录", Description: "登录 provider 和本地演示账号配置"}, +} + +func settingsDefinitions() []models.AppSettingDefinition { + return []models.AppSettingDefinition{ + { + Key: "theme.mode", + Category: "appearance", + Label: "主题模式", + Type: settingTypeSelect, + DefaultValue: "light", + Description: "主窗口和组件库主题,systemdefault 表示跟随系统。", + Options: []models.AppSettingOption{ + {Value: "light", Label: "亮色模式"}, + {Value: "dark", Label: "暗色模式"}, + {Value: "systemdefault", Label: "跟随系统"}, + }, + }, + { + Key: "language", + Category: "appearance", + Label: "界面语言", + Type: settingTypeSelect, + DefaultValue: "zh", + Description: "应用界面语言,同时影响托盘菜单语言。", + Options: []models.AppSettingOption{ + {Value: "zh", Label: "简体中文"}, + {Value: "en", Label: "English"}, + {Value: "zh-HK", Label: "繁體中文"}, + }, + }, + { + Key: "window.start_state", + Category: "window", + Label: "启动窗口状态", + Type: settingTypeSelect, + DefaultValue: "normal", + Description: "主窗口启动时默认状态;尺寸恢复由 T-015 继续落地。", + Options: []models.AppSettingOption{ + {Value: "normal", Label: "普通窗口"}, + {Value: "maximized", Label: "最大化"}, + }, + }, + { + Key: "window.width", + Category: "window", + Label: "默认宽度", + Type: settingTypeNumber, + DefaultValue: "1280", + Description: "主窗口默认宽度,当前窗口创建逻辑使用固定默认值。", + }, + { + Key: "window.height", + Category: "window", + Label: "默认高度", + Type: settingTypeNumber, + DefaultValue: "800", + Description: "主窗口默认高度,当前窗口创建逻辑使用固定默认值。", + }, + { + Key: "window.remember_state", + Category: "window", + Label: "记住窗口状态", + Type: settingTypeBoolean, + DefaultValue: "false", + Description: "保存主窗口尺寸和最大化状态,T-015 使用该配置。", + }, + { + Key: "shortcut.open_second_window", + Category: "shortcuts", + Label: "打开第二窗口", + Type: settingTypeHotkey, + DefaultValue: "Alt+P", + Description: "对应 hotkeys 表 target=1,实际注册由 HotkeyService 管理。", + ReadOnly: true, + }, + { + Key: "shortcut.open_main_window", + Category: "shortcuts", + Label: "打开主窗口", + Type: settingTypeHotkey, + DefaultValue: "Alt+M", + Description: "对应 hotkeys 表 target=2,实际注册由 HotkeyService 管理。", + ReadOnly: true, + }, + { + Key: "data.root_mode", + Category: "data", + Label: "数据目录策略", + Type: settingTypeSelect, + DefaultValue: "app_config_dir", + Description: "默认使用系统用户配置目录下的 cmbone 目录。", + Options: []models.AppSettingOption{ + {Value: "app_config_dir", Label: "系统配置目录"}, + {Value: "custom", Label: "自定义目录"}, + }, + }, + { + Key: "data.custom_directory", + Category: "data", + Label: "自定义数据目录", + Type: settingTypePath, + DefaultValue: "", + Description: "当数据目录策略为 custom 时使用;为空表示未配置。", + }, + { + Key: "logs.level", + Category: "logs", + Label: "运行日志级别", + Type: settingTypeSelect, + DefaultValue: "info", + Description: "运行日志默认记录级别。", + Options: []models.AppSettingOption{ + {Value: "debug", Label: "Debug"}, + {Value: "info", Label: "Info"}, + {Value: "warn", Label: "Warn"}, + {Value: "error", Label: "Error"}, + }, + }, + { + Key: "logs.audit_retention_days", + Category: "logs", + Label: "审计日志保留天数", + Type: settingTypeNumber, + DefaultValue: "180", + Description: "审计日志默认保留周期。", + }, + { + Key: "logs.app_retention_days", + Category: "logs", + Label: "运行日志保留天数", + Type: settingTypeNumber, + DefaultValue: "30", + Description: "运行日志默认保留周期。", + }, + { + Key: "auth.provider", + Category: "auth", + Label: "登录 provider", + Type: settingTypeSelect, + DefaultValue: "local", + Description: "local 用于模板演示,remote 是真实后端预留入口。", + Options: []models.AppSettingOption{ + {Value: "local", Label: "Local"}, + {Value: "remote", Label: "Remote"}, + }, + }, + { + Key: "auth.local.username", + Category: "auth", + Label: "本地演示账号", + Type: settingTypeText, + DefaultValue: "admin", + Description: "本地固定账号 provider 的演示用户名。", + ReadOnly: true, + }, + { + Key: "auth.local.password_salt", + Category: "auth", + Label: "本地密码盐", + Type: settingTypeText, + DefaultValue: "cmbone-local-auth-v1", + Description: "本地演示账号密码盐。", + ReadOnly: true, + }, + { + Key: "auth.local.password_hash", + Category: "auth", + Label: "本地密码哈希", + Type: settingTypeText, + DefaultValue: "3e0bfcda604b19fef1f596bcade7469ff1ab8b427fdc998e9a8216101b002f4a", + Description: "本地演示账号密码哈希。", + ReadOnly: true, + }, + } +} + +func settingDefinitionByKey(key string) (models.AppSettingDefinition, bool) { + for _, definition := range settingsDefinitions() { + if definition.Key == key { + return definition, true + } + } + return models.AppSettingDefinition{}, false +} + +func defaultAppConfigSQL() string { + var builder strings.Builder + for _, definition := range settingsDefinitions() { + builder.WriteString(fmt.Sprintf( + "INSERT OR IGNORE INTO appconfig (key, type, value, description) VALUES (%s, %s, %s, %s);\n", + sqlString(definition.Key), + sqlString(definition.Type), + sqlString(definition.DefaultValue), + sqlString(definition.Description), + )) + } + return builder.String() +} + +func sqlString(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} diff --git a/internal/services/store_test.go b/internal/services/store_test.go index 646d087..593d54e 100644 --- a/internal/services/store_test.go +++ b/internal/services/store_test.go @@ -1,6 +1,7 @@ package services import ( + "cmbone/internal/models" "database/sql" "errors" "testing" @@ -64,6 +65,91 @@ func TestAppConfigServiceLanguage(t *testing.T) { } } +func TestAppConfigServiceSettingsSchema(t *testing.T) { + store := newTestStore(t) + service := NewAppConfigService(store) + + groups, err := service.GetSettingsSchema() + if err != nil { + t.Fatalf("get settings schema: %v", err) + } + + requiredKeys := map[string]string{ + "theme.mode": "light", + "language": "zh", + "window.start_state": "normal", + "window.width": "1280", + "window.height": "800", + "window.remember_state": "false", + "shortcut.open_second_window": "Alt+P", + "shortcut.open_main_window": "Alt+M", + "data.root_mode": "app_config_dir", + "data.custom_directory": "", + "logs.level": "info", + "logs.audit_retention_days": "180", + "logs.app_retention_days": "30", + } + + got := make(map[string]models.AppSettingDefinition) + for _, group := range groups { + for _, item := range group.Items { + got[item.Key] = item + } + } + + for key, wantDefault := range requiredKeys { + item, ok := got[key] + if !ok { + t.Fatalf("setting %q missing from schema", key) + } + if item.DefaultValue != wantDefault { + t.Fatalf("setting %q default = %q, want %q", key, item.DefaultValue, wantDefault) + } + if item.Value != wantDefault { + t.Fatalf("setting %q value = %q, want %q", key, item.Value, wantDefault) + } + if item.Options == nil { + t.Fatalf("setting %q options is nil, want empty slice", key) + } + } +} + +func TestAppConfigServiceValidatesSchemaValues(t *testing.T) { + store := newTestStore(t) + service := NewAppConfigService(store) + + if err := service.SetAppConfig("theme.mode", "dark"); err != nil { + t.Fatalf("set valid theme: %v", err) + } + gotTheme, err := service.GetAppConfig("theme.mode") + if err != nil { + t.Fatalf("get theme: %v", err) + } + if gotTheme != "dark" { + t.Fatalf("theme.mode = %q, want dark", gotTheme) + } + var gotType string + if err := store.DB.QueryRow("SELECT type FROM appconfig WHERE key = 'theme.mode'").Scan(&gotType); err != nil { + t.Fatalf("get theme type: %v", err) + } + if gotType != settingTypeSelect { + t.Fatalf("theme.mode type = %q, want %q", gotType, settingTypeSelect) + } + + if err := service.SetAppConfig("theme.mode", "purple"); err == nil { + t.Fatal("set invalid theme returned nil error") + } + if err := service.SetAppConfig("window.remember_state", "yes"); err == nil { + t.Fatal("set invalid boolean returned nil error") + } + if err := service.SetAppConfig("window.width", "wide"); err == nil { + t.Fatal("set invalid number returned nil error") + } + if err := service.SetAppConfig("shortcut.open_second_window", "Alt+O"); err == nil { + t.Fatal("set readonly shortcut returned nil error") + } +} + func TestAuthServiceLocalLoginLifecycle(t *testing.T) { store := newTestStore(t) service := NewAuthService(store) diff --git a/internal/services/systeminfo.go b/internal/services/systeminfo.go index f6d2cc6..2731838 100644 --- a/internal/services/systeminfo.go +++ b/internal/services/systeminfo.go @@ -36,21 +36,3 @@ func defaultHotkeysSQL() string { } return sqlhotkeys } - -func defaultAppConfigSQL() string { - return ` - INSERT OR IGNORE INTO appconfig (key, type, value, description) - VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en'); - - INSERT OR IGNORE INTO appconfig (key, type, value, description) - VALUES ('auth.provider', 'system', 'local', '登录 provider,支持 local/remote'); - - INSERT OR IGNORE INTO appconfig (key, type, value, description) - VALUES ('auth.local.username', 'system', 'admin', '本地演示账号用户名'); - - INSERT OR IGNORE INTO appconfig (key, type, value, description) - VALUES ('auth.local.password_salt', 'system', 'cmbone-local-auth-v1', '本地演示账号密码盐'); - - INSERT OR IGNORE INTO appconfig (key, type, value, description) - VALUES ('auth.local.password_hash', 'system', '3e0bfcda604b19fef1f596bcade7469ff1ab8b427fdc998e9a8216101b002f4a', '本地演示账号密码哈希');` -} diff --git a/progress.md b/progress.md index e0e3324..8de8f6c 100644 --- a/progress.md +++ b/progress.md @@ -199,3 +199,30 @@ - 非阻塞警告: - `dev.bat` 日志仍有 `SetProcessDpiAwarenessContext failed ... Access is denied`,不影响窗口创建和页面加载。 - Browserslist 数据过期、Vite chunk 体积警告和 `wails3 build` alias 警告仍存在。 + +### 收敛设置模块 schema + +- 任务:完成 `T-014`,让主题、语言、窗口行为、快捷键、数据目录和日志策略都有统一配置定义和默认值。 +- 结果: + - 新增 `internal/models/settings.go`,定义 `AppSettingGroup`、`AppSettingDefinition` 和 `AppSettingOption`。 + - 新增 `internal/services/settings_schema.go`,集中定义设置 key、类型、默认值、说明、选项和只读策略,并由 schema 生成 `appconfig` 初始化 SQL。 + - `AppConfigService` 新增 `GetSettingsSchema`,返回分组、当前值和默认值。 + - `SetAppConfig` 对 schema 已知项执行 select、boolean、number 和只读校验。 + - 设置页 `/#/settings` 展示后端统一 schema,主题设置改为读写 `theme.mode`。 + - 重新生成 Wails `.js` bindings。 +- 覆盖的配置分组: + - 外观与语言:`theme.mode`、`language`。 + - 窗口行为:`window.start_state`、`window.width`、`window.height`、`window.remember_state`。 + - 快捷键:`shortcut.open_second_window`、`shortcut.open_main_window`,只读展示,实际更新仍走 `HotkeyService`。 + - 数据目录:`data.root_mode`、`data.custom_directory`。 + - 日志策略:`logs.level`、`logs.audit_retention_days`、`logs.app_retention_days`。 + - 登录:`auth.provider` 和本地演示账号 hash 配置。 +- 验证: + - `go test ./...` 通过。 + - `cd frontend && npm run typecheck` 通过。 + - `cd frontend && npm run build` 通过。 + - `go build -o bin\cmbone.exe .` 通过。 + - `cmd /c build.bat` 通过,并重新生成 Wails `.js` bindings。 +- 非阻塞警告: + - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 + - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。