feat: add settings schema

This commit is contained in:
QiuSW
2026-07-09 00:12:30 +08:00
parent 5aaac0b166
commit 91debb9ad6
24 changed files with 867 additions and 53 deletions
+1 -1
View File
@@ -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 示例
- 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例
## 环境准备
+1 -1
View File
@@ -34,7 +34,7 @@
## 当前阶段
当前项目处于:可复用桌面应用模板的产品定位校准阶段。
当前项目处于:可复用桌面应用模板的 Phase 1 基础模块建设阶段。
优先路径:
+1
View File
@@ -134,6 +134,7 @@ Windows build 资源:
## 目标模块技术边界
- 设置:通过 `settings_schema.go` 集中定义 key、类型、默认值、说明、选项和只读策略。
- 登录:通过 `AuthProvider` 支持本地固定账号和真实后端,不在前端写死密码。
- 主业务:商品图片 AI 优化通过服务层封装,MVP 可先用本地模拟 provider。
- 操作日志:审计日志进 SQLite,运行日志可进 SQLite 或本地滚动日志文件。
+12 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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` | 主题工具 | 主题切换和持久化协作 |
+2 -2
View File
@@ -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
View File
@@ -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>
+2 -1
View File
@@ -51,7 +51,8 @@
"themesetting": {
"select": {
"opt_dark": "Dark",
"opt_light": "Bright"
"opt_light": "Bright",
"opt_system": "System"
}
},
"about": {
+2 -1
View File
@@ -53,7 +53,8 @@
"themesetting": {
"select": {
"opt_dark": "暗色模式",
"opt_light": "亮色模式"
"opt_light": "亮色模式",
"opt_system": "跟隨系統"
}
},
"about": {
+3 -2
View File
@@ -52,7 +52,8 @@
"themesetting":{
"select":{
"opt_light":"亮色模式",
"opt_dark":"暗色模式"
"opt_dark":"暗色模式",
"opt_system":"跟随系统"
}
},
"about": {
@@ -96,4 +97,4 @@
}
}
}
}
}
+9 -3
View File
@@ -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')
})
}
+130 -6
View File
@@ -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>
+25
View File
@@ -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"`
}
+102 -4
View File
@@ -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
}
+226
View File
@@ -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, "'", "''") + "'"
}
+86
View File
@@ -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)
-18
View File
@@ -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
View File
@@ -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 大小警告。