diff --git a/README.md b/README.md index 1becaa7..3c395e7 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 -- 当前功能:多语言、主题切换、统一设置 schema、本地配置、快捷键、本地登录 provider、OCR 示例 +- 当前功能:多语言、主题切换、统一设置 schema、主窗口状态恢复、本地配置、快捷键、本地登录 provider、OCR 示例 - 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例 ## 环境准备 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 5af6211..d79fd0d 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -15,7 +15,7 @@ ### P0 · 模板必须具备 - 应用壳:侧边导航、顶部用户区、主内容区,默认布局适合长期业务操作。 -- 主窗口:默认不最大化,初始 `1280x800`,最小 `1024x720`,居中,可调整大小。 +- 主窗口:默认不最大化,初始 `1280x800`,最小 `1024x720`,居中,可调整大小,并恢复用户上次窗口尺寸和最大化状态。 - 页面布局:根容器 `width: 100%`、`height: 100vh`,内部使用 flex/grid 填满窗口。 - 内容区:阅读型内容保留最大宽度;列表、仪表盘、设置页自适应拉伸。 - 第二窗口:保持小窗,不参与主窗口最大化和尺寸恢复逻辑。 @@ -39,7 +39,6 @@ P0 数据统计只做 6 个指标: P1 再考虑: -- 保存主窗口尺寸和最大化状态,下次启动恢复用户上次选择。 - 图片优化前后平均文件大小变化。 - 不同优化类型使用次数,例如增强、压缩、背景替换。 - 最常见错误类型 Top 5。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 9352538..542b0f2 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -218,13 +218,13 @@ image_task_results( - 主窗口承载长期业务操作,默认不最大化。 - 主窗口初始尺寸为 `1280x800`,最小尺寸为 `1024x720`。 - 主窗口启动时居中,并允许用户调整大小。 +- 主窗口按 `window.width`、`window.height`、`window.start_state` 和 `window.remember_state` 恢复用户上次选择。 - 前端根容器使用 `width: 100%` 和 `height: 100vh`。 - 主应用布局内部使用 flex 或 grid 填满窗口。 - 阅读型内容保留最大宽度,列表、仪表盘、设置页等工作型页面自适应拉伸。 - 第二窗口保持当前小窗定位,不参与最大化、尺寸恢复和主布局填充逻辑。 -- 后续增强再保存主窗口尺寸和最大化状态,下次启动恢复用户选择。 -实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地;登录 provider 边界已落地;设置模块统一 schema 已落地。窗口尺寸持久化下一步基于 `window.*` 配置实现。 +实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地;登录 provider 边界已落地;设置模块统一 schema 已落地;主窗口尺寸和最大化状态持久化已落地。 ## 关键风险 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index eee3a96..1624d9b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -31,7 +31,7 @@ | 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 | 主题、语言、窗口行为、快捷键、数据目录、日志策略都有统一配置定义和默认值 | DONE | -| T-015 | 持久化窗口尺寸和最大化状态 | T-014 | 保存主窗口尺寸和最大化状态;下次启动恢复用户上次选择;第二窗口不参与该逻辑 | TODO | +| T-015 | 持久化窗口尺寸和最大化状态 | T-014 | 保存主窗口尺寸和最大化状态;下次启动恢复用户上次选择;第二窗口不参与该逻辑 | DONE | ## Phase 2 · 业务样例与日志统计 diff --git a/docs/current-state.md b/docs/current-state.md index 0d2284e..0146353 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -1,12 +1,12 @@ # 当前状态 -更新时间:2026-07-08 +更新时间:2026-07-09 ## 当前阶段 项目处于可复用桌面应用模板的 Phase 1 基础模块建设阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。 -当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界和统一设置 schema 阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口和本地登录能力。商品图片 AI 优化、完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。 +当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界、统一设置 schema 和主窗口状态恢复阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口和本地登录能力。商品图片 AI 优化、完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。 ## 已确认事实 @@ -29,7 +29,7 @@ - 当前主应用模块路由:`/#/login`、`/#/dashboard`、`/#/image-optimizer`、`/#/logs`、`/#/statistics`、`/#/settings`、`/#/components`。 - 当前兼容重定向路由:`/#/business/image-optimizer`、`/#/logs/audit`、`/#/logs/app`。 - 当前第二窗口路由:`/#/second` -- 已实现主窗口策略:默认不最大化,初始 `1280x800`,最小 `1024x720`,居中,可调整大小。 +- 已实现主窗口策略:默认不最大化,初始 `1280x800`,最小 `1024x720`,居中,可调整大小;启动后可按 `window.*` 配置恢复尺寸和最大化状态。 - 已实现页面布局策略:根容器 `width: 100%`、`height: 100vh`,内部 flex/grid 填满窗口。 - 已实现内容区策略:阅读型内容保留最大宽度;列表、仪表盘、设置页自适应拉伸。 - 已保留第二窗口策略:保持小窗,不参与主窗口最大化和尺寸恢复逻辑。 @@ -37,6 +37,7 @@ - 已实现登录审计留痕:登录成功、登录失败和退出写入 `audit_logs`。 - 已实现统一设置 schema:主题、语言、窗口行为、快捷键展示、数据目录、日志策略和登录 provider 配置都有默认值、说明和基础校验。 - 已实现设置页 schema 展示:`/#/settings` 可读取后端 `GetSettingsSchema` 并展示分组、当前值、默认值、说明和只读标记。 +- 已实现主窗口状态持久化:主窗口普通尺寸和最大化状态会写回 `appconfig`,下次启动恢复;第二窗口不参与该逻辑。 ## 当前命令 @@ -147,9 +148,18 @@ wails3 build - `/#/settings` 展示统一设置 schema。 - `go test ./...`、`npm run typecheck`、`npm run build`、`go build -o bin\cmbone.exe .`、`cmd /c build.bat` 通过。 +2026-07-09 完成主窗口状态持久化: + +- 主窗口启动时读取 `window.width`、`window.height`、`window.start_state` 和 `window.remember_state`。 +- 主窗口 resize、maximize、restore 和 closing 时写回普通尺寸和最大化状态。 +- `window.remember_state` 默认值调整为 `true`。 +- 已补充旧默认值迁移,把 T-014 旧描述下的 `window.remember_state=false` 升级为 `true`。 +- 第二窗口仍保持固定 `340x320` 小窗,不注册尺寸恢复逻辑。 +- `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。 + ## 下一步 -从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-015`:持久化窗口尺寸和最大化状态。 +从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-020`:建立商品图片 AI 优化任务模型。 ## 当前 blocker diff --git a/docs/method-map.md b/docs/method-map.md index 7de338c..dcf4e45 100644 --- a/docs/method-map.md +++ b/docs/method-map.md @@ -8,6 +8,7 @@ | --- | --- | --- | | `main.go` | `main()` | 创建 Wails app、注册服务、嵌入资源、启动应用 | | `internal/services/appservice.go` | `ConfigureAppService` | 配置窗口、托盘、语言资源和生命周期 | +| `internal/services/window_state.go` | main window state helpers | 读取和保存主窗口尺寸、最大化状态;第二窗口不参与 | ## 存储 diff --git a/docs/routes.md b/docs/routes.md index 85e9f92..5164633 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -29,7 +29,7 @@ | `#/image-optimizer` | 商品图片 AI 优化 | `T-020` / `T-023` 接入任务模型、任务列表、状态、结果和失败原因 | | `#/logs` | 操作日志 | `T-021` 接入审计日志和运行日志查询 | | `#/statistics` | 数据统计 | `T-022` 接入趋势、成功率、耗时和失败任务 | -| `#/settings` | 设置中心 | 已接入统一设置 schema,`T-015` 继续使用窗口配置做尺寸恢复 | +| `#/settings` | 设置中心 | 已接入统一设置 schema,窗口配置已用于主窗口尺寸恢复 | | `#/components` | 组件示例 | `T-030` 扩充输入框、多选框、下拉框、多行文本、表格、筛选、分页 | ## 主窗口布局 @@ -47,14 +47,13 @@ Windows 10+ 是主要体验目标。主窗口策略已经实现到当前代码 - 最小宽高:`1024x720`。 - 启动时居中。 - 允许用户调整大小。 +- 保存主窗口尺寸和最大化状态,下次启动恢复用户上次选择。 - 页面根容器使用 `width: 100%` 和 `height: 100vh`。 - 主布局内部使用 flex 或 grid,让侧边导航、顶部区域和内容区稳定填满窗口。 - 阅读型内容使用最大宽度,建议 `max-width: 960px` 并居中。 - 列表、仪表盘、设置页等工作型页面自适应拉伸,占满可用内容区。 - 第二窗口保持小窗,不参与主窗口最大化、尺寸恢复和布局填充逻辑。 -后续增强:保存主窗口尺寸和最大化状态,下次启动恢复用户上次选择。 - ## 页面规则 - 新增页面必须先确认属于模板能力、业务样例还是开发者示例。 diff --git a/internal/services/appservice.go b/internal/services/appservice.go index 7a3742a..2205af8 100644 --- a/internal/services/appservice.go +++ b/internal/services/appservice.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "runtime" + "sync" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" @@ -17,7 +18,8 @@ type AppService struct { SecondWindow *application.WebviewWindow MainWindow *application.WebviewWindow - Config *AppConfigService + Config *AppConfigService + windowStateMu sync.Mutex Tray *application.SystemTray I18nFS embed.FS @@ -45,15 +47,16 @@ func ConfigureAppService(a *AppService, app *application.App, i18nFS embed.FS, s a.Config = NewAppConfigService(suidb) isWindows := runtime.GOOS == "windows" + mainWindowState := a.loadMainWindowStateOptions() mainWindow := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ Title: "main", Name: "main", - Width: mainWindowWidth, - Height: mainWindowHeight, + Width: mainWindowState.Width, + Height: mainWindowState.Height, MinWidth: mainWindowMinWidth, MinHeight: mainWindowMinHeight, InitialPosition: application.WindowCentered, - StartState: application.WindowStateNormal, + StartState: mainWindowState.StartState, DisableResize: false, Frameless: isWindows, Mac: application.MacWindow{ @@ -83,6 +86,7 @@ func ConfigureAppService(a *AppService, app *application.App, i18nFS embed.FS, s secondWindow.Hide() a.SecondWindow = secondWindow a.MainWindow = mainWindow + a.registerMainWindowStatePersistence() RegisterWindowAndCallback(HotkeyShowSecondWindow, a.SecondWindow, func() { if a.SecondWindow == nil { @@ -93,6 +97,7 @@ func ConfigureAppService(a *AppService, app *application.App, i18nFS embed.FS, s }) a.MainWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + a.persistMainWindowState() a.MainWindow.Hide() e.Cancel() }) diff --git a/internal/services/settings_schema.go b/internal/services/settings_schema.go index b9a943e..29640eb 100644 --- a/internal/services/settings_schema.go +++ b/internal/services/settings_schema.go @@ -58,7 +58,7 @@ func settingsDefinitions() []models.AppSettingDefinition { Label: "启动窗口状态", Type: settingTypeSelect, DefaultValue: "normal", - Description: "主窗口启动时默认状态;尺寸恢复由 T-015 继续落地。", + Description: "主窗口启动时恢复的窗口状态。", Options: []models.AppSettingOption{ {Value: "normal", Label: "普通窗口"}, {Value: "maximized", Label: "最大化"}, @@ -70,7 +70,7 @@ func settingsDefinitions() []models.AppSettingDefinition { Label: "默认宽度", Type: settingTypeNumber, DefaultValue: "1280", - Description: "主窗口默认宽度,当前窗口创建逻辑使用固定默认值。", + Description: "主窗口普通状态下保存的宽度。", }, { Key: "window.height", @@ -78,15 +78,15 @@ func settingsDefinitions() []models.AppSettingDefinition { Label: "默认高度", Type: settingTypeNumber, DefaultValue: "800", - Description: "主窗口默认高度,当前窗口创建逻辑使用固定默认值。", + Description: "主窗口普通状态下保存的高度。", }, { Key: "window.remember_state", Category: "window", Label: "记住窗口状态", Type: settingTypeBoolean, - DefaultValue: "false", - Description: "保存主窗口尺寸和最大化状态,T-015 使用该配置。", + DefaultValue: "true", + Description: "保存并恢复主窗口尺寸和最大化状态。", }, { Key: "shortcut.open_second_window", @@ -221,6 +221,17 @@ func defaultAppConfigSQL() string { return builder.String() } +func appConfigDataMigrationSQL() string { + return ` + UPDATE appconfig + SET value = 'true', + description = '保存并恢复主窗口尺寸和最大化状态。' + WHERE key = 'window.remember_state' + AND value = 'false' + AND description = '保存主窗口尺寸和最大化状态,T-015 使用该配置。'; + ` +} + func sqlString(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } diff --git a/internal/services/store.go b/internal/services/store.go index f6dbe28..d51bbca 100644 --- a/internal/services/store.go +++ b/internal/services/store.go @@ -113,6 +113,9 @@ func initDefaultData(db *sql.DB) error { if _, err := db.Exec(defaultAppConfigSQL()); err != nil { return err } + if _, err := db.Exec(appConfigDataMigrationSQL()); err != nil { + return err + } if _, err := db.Exec(defaultHotkeysSQL()); err != nil { return err } diff --git a/internal/services/store_test.go b/internal/services/store_test.go index 593d54e..1095a3e 100644 --- a/internal/services/store_test.go +++ b/internal/services/store_test.go @@ -80,7 +80,7 @@ func TestAppConfigServiceSettingsSchema(t *testing.T) { "window.start_state": "normal", "window.width": "1280", "window.height": "800", - "window.remember_state": "false", + "window.remember_state": "true", "shortcut.open_second_window": "Alt+P", "shortcut.open_main_window": "Alt+M", "data.root_mode": "app_config_dir", @@ -114,6 +114,31 @@ func TestAppConfigServiceSettingsSchema(t *testing.T) { } } +func TestMigrateUpgradesWindowRememberStateOldDefault(t *testing.T) { + store := newTestStore(t) + + if _, err := store.DB.Exec(` + UPDATE appconfig + SET value = 'false', + description = '保存主窗口尺寸和最大化状态,T-015 使用该配置。' + WHERE key = 'window.remember_state' + `); err != nil { + t.Fatalf("set old window remember default: %v", err) + } + + if err := Migrate(store.DB); err != nil { + t.Fatalf("migrate old window remember default: %v", err) + } + + var value string + if err := store.DB.QueryRow("SELECT value FROM appconfig WHERE key = 'window.remember_state'").Scan(&value); err != nil { + t.Fatalf("query window remember state: %v", err) + } + if value != "true" { + t.Fatalf("window.remember_state = %q, want true", value) + } +} + func TestAppConfigServiceValidatesSchemaValues(t *testing.T) { store := newTestStore(t) service := NewAppConfigService(store) diff --git a/internal/services/window_state.go b/internal/services/window_state.go new file mode 100644 index 0000000..af607a2 --- /dev/null +++ b/internal/services/window_state.go @@ -0,0 +1,165 @@ +package services + +import ( + "log" + "strconv" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +const ( + windowStartStateNormal = "normal" + windowStartStateMaximized = "maximized" + + settingKeyWindowStartState = "window.start_state" + settingKeyWindowWidth = "window.width" + settingKeyWindowHeight = "window.height" + settingKeyWindowRememberState = "window.remember_state" +) + +type mainWindowStateOptions struct { + Width int + Height int + StartState application.WindowState + Remember bool +} + +func (a *AppService) loadMainWindowStateOptions() mainWindowStateOptions { + options := mainWindowStateOptions{ + Width: mainWindowWidth, + Height: mainWindowHeight, + StartState: application.WindowStateNormal, + Remember: true, + } + if a == nil || a.Config == nil { + return options + } + + options.Remember = configBool(a.Config, settingKeyWindowRememberState, true) + if !options.Remember { + return options + } + + options.Width = max(configInt(a.Config, settingKeyWindowWidth, mainWindowWidth), mainWindowMinWidth) + options.Height = max(configInt(a.Config, settingKeyWindowHeight, mainWindowHeight), mainWindowMinHeight) + if configString(a.Config, settingKeyWindowStartState, windowStartStateNormal) == windowStartStateMaximized { + options.StartState = application.WindowStateMaximised + } + return options +} + +func (a *AppService) registerMainWindowStatePersistence() { + if a == nil || a.MainWindow == nil { + return + } + + a.MainWindow.OnWindowEvent(events.Common.WindowDidResize, func(_ *application.WindowEvent) { + a.persistMainWindowNormalSize() + }) + a.MainWindow.OnWindowEvent(events.Common.WindowMaximise, func(_ *application.WindowEvent) { + a.persistMainWindowStartState(windowStartStateMaximized) + }) + a.MainWindow.OnWindowEvent(events.Common.WindowRestore, func(_ *application.WindowEvent) { + a.persistMainWindowState() + }) + a.MainWindow.OnWindowEvent(events.Common.WindowUnMaximise, func(_ *application.WindowEvent) { + a.persistMainWindowState() + }) +} + +func (a *AppService) shouldRememberMainWindowState() bool { + if a == nil || a.Config == nil { + return false + } + return configBool(a.Config, settingKeyWindowRememberState, true) +} + +func (a *AppService) persistMainWindowState() { + if !a.shouldRememberMainWindowState() || a.MainWindow == nil { + return + } + if a.MainWindow.IsMaximised() { + a.persistMainWindowStartState(windowStartStateMaximized) + return + } + if a.MainWindow.IsFullscreen() || a.MainWindow.IsMinimised() { + return + } + a.persistMainWindowNormalSize() +} + +func (a *AppService) persistMainWindowStartState(state string) { + if !a.shouldRememberMainWindowState() || a.Config == nil { + return + } + + a.windowStateMu.Lock() + defer a.windowStateMu.Unlock() + + if err := a.Config.SetAppConfig(settingKeyWindowStartState, state); err != nil { + log.Printf("persist main window state failed: %v", err) + } +} + +func (a *AppService) persistMainWindowNormalSize() { + if !a.shouldRememberMainWindowState() || a.MainWindow == nil || a.Config == nil { + return + } + if a.MainWindow.IsMaximised() || a.MainWindow.IsFullscreen() || a.MainWindow.IsMinimised() { + return + } + + width, height := a.MainWindow.Size() + if width < mainWindowMinWidth || height < mainWindowMinHeight { + return + } + + a.windowStateMu.Lock() + defer a.windowStateMu.Unlock() + + if err := a.Config.SetAppConfig(settingKeyWindowStartState, windowStartStateNormal); err != nil { + log.Printf("persist main window start state failed: %v", err) + } + if err := a.Config.SetAppConfig(settingKeyWindowWidth, strconv.Itoa(width)); err != nil { + log.Printf("persist main window width failed: %v", err) + } + if err := a.Config.SetAppConfig(settingKeyWindowHeight, strconv.Itoa(height)); err != nil { + log.Printf("persist main window height failed: %v", err) + } +} + +func configString(config *AppConfigService, key string, fallback string) string { + if config == nil { + return fallback + } + value, err := config.GetAppConfig(key) + if err != nil || value == "" { + return fallback + } + return value +} + +func configBool(config *AppConfigService, key string, fallback bool) bool { + value := configString(config, key, "") + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} + +func configInt(config *AppConfigService, key string, fallback int) int { + value := configString(config, key, "") + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/internal/services/window_state_test.go b/internal/services/window_state_test.go new file mode 100644 index 0000000..e7cd596 --- /dev/null +++ b/internal/services/window_state_test.go @@ -0,0 +1,101 @@ +package services + +import ( + "testing" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +func TestLoadMainWindowStateOptionsDefaults(t *testing.T) { + store := newTestStore(t) + service := &AppService{Config: NewAppConfigService(store)} + + options := service.loadMainWindowStateOptions() + if options.Width != mainWindowWidth { + t.Fatalf("width = %d, want %d", options.Width, mainWindowWidth) + } + if options.Height != mainWindowHeight { + t.Fatalf("height = %d, want %d", options.Height, mainWindowHeight) + } + if options.StartState != application.WindowStateNormal { + t.Fatalf("start state = %v, want normal", options.StartState) + } + if !options.Remember { + t.Fatal("remember = false, want true") + } +} + +func TestLoadMainWindowStateOptionsUsesSavedValues(t *testing.T) { + store := newTestStore(t) + config := NewAppConfigService(store) + service := &AppService{Config: config} + + if err := config.SetAppConfig(settingKeyWindowWidth, "1440"); err != nil { + t.Fatalf("set width: %v", err) + } + if err := config.SetAppConfig(settingKeyWindowHeight, "900"); err != nil { + t.Fatalf("set height: %v", err) + } + if err := config.SetAppConfig(settingKeyWindowStartState, windowStartStateMaximized); err != nil { + t.Fatalf("set start state: %v", err) + } + + options := service.loadMainWindowStateOptions() + if options.Width != 1440 { + t.Fatalf("width = %d, want 1440", options.Width) + } + if options.Height != 900 { + t.Fatalf("height = %d, want 900", options.Height) + } + if options.StartState != application.WindowStateMaximised { + t.Fatalf("start state = %v, want maximised", options.StartState) + } +} + +func TestLoadMainWindowStateOptionsIgnoresSavedValuesWhenDisabled(t *testing.T) { + store := newTestStore(t) + config := NewAppConfigService(store) + service := &AppService{Config: config} + + if err := config.SetAppConfig(settingKeyWindowRememberState, "false"); err != nil { + t.Fatalf("set remember state: %v", err) + } + if err := config.SetAppConfig(settingKeyWindowWidth, "1440"); err != nil { + t.Fatalf("set width: %v", err) + } + if err := config.SetAppConfig(settingKeyWindowStartState, windowStartStateMaximized); err != nil { + t.Fatalf("set start state: %v", err) + } + + options := service.loadMainWindowStateOptions() + if options.Width != mainWindowWidth { + t.Fatalf("width = %d, want %d", options.Width, mainWindowWidth) + } + if options.StartState != application.WindowStateNormal { + t.Fatalf("start state = %v, want normal", options.StartState) + } + if options.Remember { + t.Fatal("remember = true, want false") + } +} + +func TestLoadMainWindowStateOptionsClampsSmallSavedSize(t *testing.T) { + store := newTestStore(t) + config := NewAppConfigService(store) + service := &AppService{Config: config} + + if _, err := store.DB.Exec("UPDATE appconfig SET value = '900' WHERE key = ?", settingKeyWindowWidth); err != nil { + t.Fatalf("set raw width: %v", err) + } + if _, err := store.DB.Exec("UPDATE appconfig SET value = '600' WHERE key = ?", settingKeyWindowHeight); err != nil { + t.Fatalf("set raw height: %v", err) + } + + options := service.loadMainWindowStateOptions() + if options.Width != mainWindowMinWidth { + t.Fatalf("width = %d, want %d", options.Width, mainWindowMinWidth) + } + if options.Height != mainWindowMinHeight { + t.Fatalf("height = %d, want %d", options.Height, mainWindowMinHeight) + } +} diff --git a/progress.md b/progress.md index 8de8f6c..02b46e6 100644 --- a/progress.md +++ b/progress.md @@ -226,3 +226,23 @@ - 非阻塞警告: - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。 + +### 持久化窗口尺寸和最大化状态 + +- 任务:完成 `T-015`,保存主窗口尺寸和最大化状态,下次启动恢复用户上次选择,第二窗口不参与该逻辑。 +- 结果: + - 新增 `internal/services/window_state.go`,集中处理主窗口状态读取、恢复和写回。 + - 主窗口启动时读取 `window.width`、`window.height`、`window.start_state` 和 `window.remember_state`。 + - 主窗口 resize、maximize、restore 和 closing 时保存普通尺寸和最大化状态。 + - `window.remember_state` 默认值调整为 `true`,关闭后默认恢复用户上次选择。 + - 补充旧默认值迁移,把 T-014 旧描述下的 `window.remember_state=false` 升级为 `true`。 + - 第二窗口仍固定为 `340x320`,没有注册窗口状态持久化逻辑。 + - 新增窗口状态配置单元测试。 +- 验证: + - `go test ./...` 通过。 + - `cd frontend && npm run build` 通过。 + - `go build -o bin\cmbone.exe .` 通过。 + - `cmd /c build.bat` 通过。 +- 非阻塞警告: + - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 + - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。