feat: add settings schema
This commit is contained in:
@@ -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 示例
|
||||
- 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例
|
||||
|
||||
## 环境准备
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目处于:可复用桌面应用模板的产品定位校准阶段。
|
||||
当前项目处于:可复用桌面应用模板的 Phase 1 基础模块建设阶段。
|
||||
|
||||
优先路径:
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ Windows build 资源:
|
||||
|
||||
## 目标模块技术边界
|
||||
|
||||
- 设置:通过 `settings_schema.go` 集中定义 key、类型、默认值、说明、选项和只读策略。
|
||||
- 登录:通过 `AuthProvider` 支持本地固定账号和真实后端,不在前端写死密码。
|
||||
- 主业务:商品图片 AI 优化通过服务层封装,MVP 可先用本地模拟 provider。
|
||||
- 操作日志:审计日志进 SQLite,运行日志可进 SQLite 或本地滚动日志文件。
|
||||
|
||||
+12
-1
@@ -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.*` 配置实现。
|
||||
|
||||
## 关键风险
|
||||
|
||||
|
||||
+1
-1
@@ -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 · 业务样例与日志统计
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
|
||||
+4
-1
@@ -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` | 主题工具 | 主题切换和持久化协作 |
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
| --- | --- | --- |
|
||||
| 项目结构 | B | 已从模板整理为普通工程,目录职责基本清楚 |
|
||||
| 产品定位 | B | 已校准为 Windows-first 可复用桌面应用模板,后续需要代码落地 |
|
||||
| 后端服务 | B | 现有服务边界清晰,登录、日志、统计和业务样例服务尚未实现 |
|
||||
| 后端服务 | B+ | 现有服务边界清晰,登录 provider 和设置 schema 已落地,日志、统计和业务样例服务尚未实现 |
|
||||
| 本地存储 | B | SQLite 初始化和基础测试已存在,目标新增表需要迁移和测试 |
|
||||
| 前端结构 | B | Vue 组件按功能拆分,窗口和布局策略已落地,目标路由骨架尚未实现 |
|
||||
| 前端结构 | B+ | Vue 组件按功能拆分,窗口、布局策略和目标路由骨架已落地,设置页已接入后端 schema |
|
||||
| 构建验证 | B | Go 测试、前端构建、后端编译可运行 |
|
||||
| 文档 | B+ | Harness Coding 文档已接入,并同步了模板愿景 |
|
||||
|
||||
|
||||
+2
-2
@@ -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` 扩充输入框、多选框、下拉框、多行文本、表格、筛选、分页 |
|
||||
|
||||
## 主窗口布局
|
||||
|
||||
@@ -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<AppSettingDefinition>} [$$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<AppSettingDefinition>} */($$parsedSource));
|
||||
}
|
||||
}
|
||||
|
||||
export class AppSettingGroup {
|
||||
/**
|
||||
* Creates a new AppSettingGroup instance.
|
||||
* @param {Partial<AppSettingGroup>} [$$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<AppSettingGroup>} */($$parsedSource));
|
||||
}
|
||||
}
|
||||
|
||||
export class AppSettingOption {
|
||||
/**
|
||||
* Creates a new AppSettingOption instance.
|
||||
* @param {Partial<AppSettingOption>} [$$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<AppSettingOption>} */($$parsedSource));
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthProviderOption {
|
||||
/**
|
||||
* Creates a new AuthProviderOption instance.
|
||||
@@ -176,3 +352,9 @@ export class Hotkey {
|
||||
return new Hotkey(/** @type {Partial<Hotkey>} */($$parsedSource));
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = AppSettingOption.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = AppSettingDefinition.createFrom;
|
||||
const $$createType3 = $Create.Array($$createType2);
|
||||
|
||||
@@ -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<models$0.AppSettingGroup[]> & { 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);
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { setTheme, getCurrentTheme, ThemeMode } from '../../utils/ThemeManager'
|
||||
import { GetAppConfig, SetAppConfig } from '../../../bindings/cmbone/internal/services/appconfigservice'
|
||||
import { setTheme, getCurrentTheme, type ThemeMode } from '../../utils/ThemeManager'
|
||||
|
||||
const themevalue = ref<ThemeMode>('light')
|
||||
|
||||
const themeChange = (val: ThemeMode) => {
|
||||
const themeChange = async (val: ThemeMode) => {
|
||||
setTheme(val)
|
||||
await SetAppConfig('theme.mode', val)
|
||||
// 通知子窗口也可以加上 BroadcastChannel
|
||||
const bc = new BroadcastChannel('theme')
|
||||
bc.postMessage(val)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
themevalue.value = getCurrentTheme()
|
||||
onMounted(async () => {
|
||||
const savedTheme = await GetAppConfig('theme.mode')
|
||||
themevalue.value = (savedTheme || getCurrentTheme()) as ThemeMode
|
||||
setTheme(themevalue.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -25,6 +29,7 @@ onMounted(() => {
|
||||
>
|
||||
<a-select-option value="light">{{$t('components.themesetting.select.opt_light')}}</a-select-option>
|
||||
<a-select-option value="dark">{{$t('components.themesetting.select.opt_dark')}}</a-select-option>
|
||||
<a-select-option value="systemdefault">{{$t('components.themesetting.select.opt_system')}}</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
"themesetting": {
|
||||
"select": {
|
||||
"opt_dark": "Dark",
|
||||
"opt_light": "Bright"
|
||||
"opt_light": "Bright",
|
||||
"opt_system": "System"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
"themesetting": {
|
||||
"select": {
|
||||
"opt_dark": "暗色模式",
|
||||
"opt_light": "亮色模式"
|
||||
"opt_light": "亮色模式",
|
||||
"opt_system": "跟隨系統"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"themesetting":{
|
||||
"select":{
|
||||
"opt_light":"亮色模式",
|
||||
"opt_dark":"暗色模式"
|
||||
"opt_dark":"暗色模式",
|
||||
"opt_system":"跟随系统"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
@@ -96,4 +97,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import ModulePageShell from '../../components/template/ModulePageShell.vue'
|
||||
import GeneralSettings from '../../components/General/Index.vue'
|
||||
import { GetSettingsSchema } from '../../../bindings/cmbone/internal/services/appconfigservice'
|
||||
|
||||
const summary = [
|
||||
{ label: '主题', value: '可配置', tone: 'slate' as const },
|
||||
{ label: '语言', value: '可配置', tone: 'blue' as const },
|
||||
{ label: '快捷键', value: '已接入', tone: 'green' as const },
|
||||
{ label: '窗口行为', value: '待 schema', tone: 'amber' as const },
|
||||
]
|
||||
type SettingOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type SettingItem = {
|
||||
key: string
|
||||
category: string
|
||||
label: string
|
||||
type: string
|
||||
defaultValue: string
|
||||
value: string
|
||||
description: string
|
||||
options: SettingOption[]
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
type SettingGroup = {
|
||||
category: string
|
||||
label: string
|
||||
description: string
|
||||
items: SettingItem[]
|
||||
}
|
||||
|
||||
const groups = ref<SettingGroup[]>([])
|
||||
const isLoading = ref(true)
|
||||
const loadError = ref('')
|
||||
|
||||
const summary = computed(() => [
|
||||
{ label: 'Schema 分组', value: String(groups.value.length), tone: 'slate' as const },
|
||||
{ label: '配置项', value: String(groups.value.reduce((total, group) => total + group.items.length, 0)), tone: 'blue' as const },
|
||||
{ label: '快捷键', value: findSettingValue('shortcut.open_second_window') || '已接入', tone: 'green' as const },
|
||||
{ label: '窗口行为', value: findSettingValue('window.start_state') || 'normal', tone: 'amber' as const },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
groups.value = await GetSettingsSchema()
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function findSettingValue(key: string) {
|
||||
for (const group of groups.value) {
|
||||
const item = group.items.find((setting) => setting.key === key)
|
||||
if (item) {
|
||||
return item.value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function displayValue(item: SettingItem) {
|
||||
if (!item.value) {
|
||||
return item.defaultValue || '-'
|
||||
}
|
||||
const option = item.options.find((candidate) => candidate.value === item.value)
|
||||
return option?.label || item.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,5 +79,71 @@ const summary = [
|
||||
<section class="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||
<GeneralSettings />
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="border-b border-slate-200 p-4 dark:border-slate-800">
|
||||
<h2 class="mb-1 text-base font-semibold text-slate-950 dark:text-white">
|
||||
统一设置 Schema
|
||||
</h2>
|
||||
<p class="mb-0 text-sm text-slate-500 dark:text-slate-400">
|
||||
后端初始化默认值,前端和后续服务按 key 使用同一份定义。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="p-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="loadError" class="p-4 text-sm text-rose-600 dark:text-rose-300">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
<div v-else class="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
<div v-for="group in groups" :key="group.category" class="p-4">
|
||||
<div class="mb-3 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{{ group.label }}
|
||||
</h3>
|
||||
<p class="mb-0 text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ group.description }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400">{{ group.category }}</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="w-[220px] py-2 pr-3 font-medium">Key</th>
|
||||
<th class="w-[160px] py-2 pr-3 font-medium">当前值</th>
|
||||
<th class="w-[160px] py-2 pr-3 font-medium">默认值</th>
|
||||
<th class="py-2 pr-3 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-for="item in group.items" :key="item.key">
|
||||
<td class="py-2 pr-3">
|
||||
<div class="font-medium text-slate-900 dark:text-white">{{ item.label }}</div>
|
||||
<div class="text-xs text-slate-500 dark:text-slate-400">{{ item.key }}</div>
|
||||
</td>
|
||||
<td class="py-2 pr-3 text-slate-700 dark:text-slate-200">
|
||||
{{ displayValue(item) }}
|
||||
</td>
|
||||
<td class="py-2 pr-3 text-slate-500 dark:text-slate-400">
|
||||
{{ item.defaultValue || '-' }}
|
||||
</td>
|
||||
<td class="py-2 pr-3 text-slate-500 dark:text-slate-400">
|
||||
<span>{{ item.description }}</span>
|
||||
<span v-if="item.readOnly" class="ml-2 rounded-md bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||
只读
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ModulePageShell>
|
||||
</template>
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "'", "''") + "'"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '本地演示账号密码哈希');`
|
||||
}
|
||||
|
||||
+27
@@ -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 大小警告。
|
||||
|
||||
Reference in New Issue
Block a user