// ============================================================================ // settings.go — 应用配置 // // AppSettings 结构体(含各 Tab 设置项)、默认值、字段校验(normalizeSettings)、 // 配置读写(loadSettings/saveSettings)与运行时应用(applySettings)。 // ============================================================================ package main import ( "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "sync" ) // 默认配置:首次运行或字段为空时使用 const ( DefaultHotkey = "CmdOrCtrl+Shift+G" // 默认呼出窗口热键 DefaultAPIAddr = "127.0.0.1:17890" // 内置 Gin API 服务默认监听地址(仅本机) DefaultStockUpColor = "#ff5a5a" // 股票上涨颜色(红涨) DefaultStockDownColor = "#4d8dff" // 股票下跌颜色(蓝跌) DefaultStockRefreshSecs = 30 // 股市自动刷新间隔(秒) DefaultStockClosedRefreshSecs = 7200 // 股市休市时自动刷新间隔(秒),默认 2 小时 DefaultNewsRefreshMinutes = 120 // 新闻自动刷新间隔(分钟) DefaultNewsCount = 30 // 新闻页显示条数 DefaultGithubRefreshMinutes = 120 // Github 自动刷新间隔(分钟) AppVersion = "0.1.2" // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示 ) // LinkItem 常用网址条目 type LinkItem struct { Name string `json:"name"` // 网站名称 URL string `json:"url"` // 网站地址 } // CustomTab 用户自定义 Tab 页面 type CustomTab struct { Name string `json:"name"` // 页面名称(Tab 按钮显示文字) URL string `json:"url"` // 页面地址(iframe 加载) Desc string `json:"desc"` // 备注/提示信息(可空),Tab 说明文字优先显示 } // RepoItem 关注的 GitHub 仓库条目 type RepoItem struct { URL string `json:"url"` // 仓库地址,如 https://github.com/wailsapp/wails Desc string `json:"desc"` // 项目说明 Version string `json:"version"` // 本地已安装版本(用于与最新 release 比较),可为空 } // TickerConfig 置顶滚动条(股票 / 新闻)的显示配置 type TickerConfig struct { Enabled bool `json:"enabled"` // 是否显示滚动条窗口 Width int `json:"width"` // 窗口宽度(px),0 表示默认 1400 FontSize int `json:"fontSize"` // 字号(px) Color string `json:"color"` // 文字颜色 Speed int `json:"speed"` // 滚动速度(px/s) Transparent bool `json:"transparent"` // 背景透明,不遮挡下方内容 X int `json:"x"` // 窗口位置,-1 表示未设置(系统默认位置) Y int `json:"y"` // 窗口位置,-1 表示未设置 } // AppSettings 保存全部可自定义配置,序列化为 JSON 存于用户配置目录 type AppSettings struct { Theme string `json:"theme"` // 界面主题:dark / light Autostart bool `json:"autostart"` // 系统启动时运行 AlwaysOnTop bool `json:"alwaysOnTop"` // 窗口置顶 Hotkey string `json:"hotkey"` // 呼出(显示)窗口的全局热键 TabOrder []string `json:"tabOrder"` // Tab 显示顺序(tabId 列表,未列出的追加到末尾) StockCodes []string `json:"stockCodes"` // 股市页展示的股票代码列表 Links []LinkItem `json:"links"` // 常用网址列表 NewsKeywords string `json:"newsKeywords"` // 新闻关注关键词,逗号分隔 BiliCookie string `json:"biliCookie"` // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」 GithubRepos []RepoItem `json:"githubRepos"` // 关注的 GitHub 仓库列表 CustomTabs []CustomTab `json:"customTabs"` // 用户自定义 Tab 页面(名字 + 网址) APIAddr string `json:"apiAddr"` // 内置 API 服务监听地址 TodoPath string `json:"todoPath"` // 待办(TODO)文件路径 RecordPath string `json:"recordPath"` // 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容) RecordDBPath string `json:"recordDBPath"` // 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储) BackupDir string `json:"backupDir"` // 文本文件备份目录(文件类自定义 Tab「备份」按钮使用) StockTicker TickerConfig `json:"stockTicker"` // 股票滚动条配置 NewsTicker TickerConfig `json:"newsTicker"` // 新闻滚动条配置 Poem PoemConfig `json:"poem"` // 随机古诗显示配置 StockUpColor string `json:"stockUpColor"` // 股票上涨颜色 StockDownColor string `json:"stockDownColor"` // 股票下跌颜色 StockRefreshSecs int `json:"stockRefreshSecs"` // 股市自动刷新间隔(秒) StockClosedRefreshSecs int `json:"stockClosedRefreshSecs"` // 股市休市时自动刷新间隔(秒) NewsRefreshMinutes int `json:"newsRefreshMinutes"` // 新闻自动刷新间隔(分钟) NewsCount int `json:"newsCount"` // 新闻页显示条数 GithubRefreshMinutes int `json:"githubRefreshMinutes"` // Github 自动刷新间隔(分钟) FontDelta int `json:"fontDelta"` // 全局字号偏移(px),+-3 范围内调整 } // 当前已注册的热键(用于保存设置时先注销旧热键) var ( hotkeyMu sync.Mutex activeHotkey string ) // defaultSettings 返回默认配置:开机启动默认开启,热键与页面地址使用内置值 func defaultSettings() AppSettings { return AppSettings{ Theme: "dark", Autostart: true, AlwaysOnTop: false, Hotkey: DefaultHotkey, TabOrder: []string{"links", "news", "github", "stock", "todo", "record", "tools", "host", "settings"}, // 默认 Tab 顺序(控制/硬件等固定网页已改为自定义 Tab) StockCodes: []string{"sh000001", "sh600519", "sz000001"}, Links: []LinkItem{ {Name: "煎蛋", URL: "https://jandan.net/"}, {Name: "头条", URL: "https://www.toutiao.com/"}, {Name: "GitHub", URL: "https://github.com/"}, }, NewsKeywords: "", GithubRepos: []RepoItem{ {URL: "https://github.com/wailsapp/wails", Desc: "Wails:Go + Web 桌面应用框架"}, }, APIAddr: DefaultAPIAddr, TodoPath: "", // 待办路径:无默认值,用户须在设置中配置(空值时强制进入设置) RecordPath: "", // 记录路径:无默认值,用户须在设置中配置(空值时强制进入设置) RecordDBPath: "", // 记录数据库路径:无默认值,用户须在设置中配置(空值时强制进入设置) BackupDir: "", // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置) StockTicker: TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#ff4d4d", Speed: 80, Transparent: true, X: -1, Y: -1}, NewsTicker: TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#3ddc84", Speed: 60, Transparent: true, X: -1, Y: -1}, Poem: PoemConfig{Enabled: false, Color: DefaultPoemColor, FontSize: DefaultPoemFontSize, TTS: false, Interval: DefaultPoemInterval, Speed: DefaultPoemSpeed}, StockUpColor: DefaultStockUpColor, StockDownColor: DefaultStockDownColor, StockRefreshSecs: DefaultStockRefreshSecs, StockClosedRefreshSecs: DefaultStockClosedRefreshSecs, NewsRefreshMinutes: DefaultNewsRefreshMinutes, NewsCount: DefaultNewsCount, GithubRefreshMinutes: DefaultGithubRefreshMinutes, FontDelta: 0, // 全局字号偏移默认 0 } } // settingsFilePath 返回配置文件路径:<用户配置目录>/TengTuToolbox/settings.json func settingsFilePath() (string, error) { dir, err := os.UserConfigDir() // 例如 C:\Users\<用户名>\AppData\Roaming if err != nil { return "", err } return filepath.Join(dir, "TengTuToolbox", "settings.json"), nil } // loadSettings 读取配置文件;文件不存在或损坏时回退到默认配置 func loadSettings() AppSettings { defaults := defaultSettings() path, err := settingsFilePath() if err != nil { return defaults } data, err := os.ReadFile(path) if err != nil { return defaults // 首次运行:无配置文件,使用默认值 } settings := defaults if err := json.Unmarshal(data, &settings); err != nil { return defaults // 文件损坏:回退默认值,避免启动失败 } return normalizeSettings(settings) } // saveSettings 将配置写入磁盘(目录不存在时自动创建) func saveSettings(settings AppSettings) error { path, err := settingsFilePath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(settings, "", " ") // 缩进保存,便于手动查看 if err != nil { return err } return os.WriteFile(path, data, 0o644) } // normalizeSettings 校正配置字段:空值回填默认、主题取值限定、股票代码规范化 func normalizeSettings(settings AppSettings) AppSettings { if settings.Theme != "light" { settings.Theme = "dark" // 只允许 dark / light } if strings.TrimSpace(settings.Hotkey) == "" { settings.Hotkey = DefaultHotkey } // 先清洗自定义 Tab(生成 custom-N id 白名单供下方 Tab 顺序校验使用) tabs := make([]CustomTab, 0, len(settings.CustomTabs)) for _, tab := range settings.CustomTabs { tab.Name = strings.TrimSpace(tab.Name) tab.URL = strings.TrimSpace(tab.URL) tab.Desc = strings.TrimSpace(tab.Desc) // 备注可空 if tab.Name != "" && tab.URL != "" { // 名字与网址都非空才保留 tabs = append(tabs, tab) } } settings.CustomTabs = tabs // Tab 顺序:只保留已存在的 tabId(内置 + 自定义 custom-N),去重;未列出的追加到末尾,保证全部 Tab 都能显示 validTabs := map[string]bool{"links": true, "news": true, "github": true, "stock": true, "todo": true, "record": true, "tools": true, "host": true, "settings": true} for i := range settings.CustomTabs { validTabs[fmt.Sprintf("custom-%d", i)] = true // 自定义 Tab id:custom-0, custom-1... } seen := map[string]bool{} ordered := make([]string, 0, len(validTabs)) for _, tab := range settings.TabOrder { if validTabs[tab] && !seen[tab] { seen[tab] = true ordered = append(ordered, tab) } } for _, tab := range []string{"links", "news", "github", "stock", "todo", "record", "tools", "host", "settings"} { if !seen[tab] { ordered = append(ordered, tab) // 设置中未列出的内置 Tab 追加到末尾 } } for i := range settings.CustomTabs { id := fmt.Sprintf("custom-%d", i) if !seen[id] { ordered = append(ordered, id) // 设置中未列出的自定义 Tab 追加到末尾 } } settings.TabOrder = ordered if strings.TrimSpace(settings.APIAddr) == "" { settings.APIAddr = DefaultAPIAddr } // TodoPath / RecordPath / RecordDBPath / BackupDir 无默认值:空值保留,前端据此强制用户进入设置配置(不做硬编码回填) settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字 settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60) // 新闻条默认绿字 settings.Poem = normalizePoemConfig(settings.Poem) // 古诗显示配置校正 if !isHexColor(settings.StockUpColor) { settings.StockUpColor = DefaultStockUpColor // 涨色回填默认红 } if !isHexColor(settings.StockDownColor) { settings.StockDownColor = DefaultStockDownColor // 跌色回填默认蓝 } if settings.StockRefreshSecs < 5 || settings.StockRefreshSecs > 3600 { settings.StockRefreshSecs = DefaultStockRefreshSecs // 股市刷新间隔限定 5 秒 ~ 1 小时 } if settings.StockClosedRefreshSecs < 60 || settings.StockClosedRefreshSecs > 86400 { settings.StockClosedRefreshSecs = DefaultStockClosedRefreshSecs // 休市刷新间隔限定 1 分钟 ~ 1 天 } if settings.NewsRefreshMinutes < 5 || settings.NewsRefreshMinutes > 1440 { settings.NewsRefreshMinutes = DefaultNewsRefreshMinutes // 新闻刷新间隔限定 5 分钟 ~ 1 天 } if settings.NewsCount < 5 || settings.NewsCount > 200 { settings.NewsCount = DefaultNewsCount // 新闻显示条数限定 5 ~ 200 条 } if settings.GithubRefreshMinutes < 5 || settings.GithubRefreshMinutes > 1440 { settings.GithubRefreshMinutes = DefaultGithubRefreshMinutes // Github 刷新间隔限定 5 分钟 ~ 1 天 } if settings.FontDelta < -3 || settings.FontDelta > 3 { settings.FontDelta = 0 // 全局字号偏移限定 +-3 px } if settings.StockCodes == nil { settings.StockCodes = defaultSettings().StockCodes // 未设置时用默认自选股 } if settings.Links == nil { settings.Links = defaultSettings().Links // 未设置时用默认网址 } if settings.GithubRepos == nil { settings.GithubRepos = defaultSettings().GithubRepos // 未设置时用默认仓库 } links := make([]LinkItem, 0, len(settings.Links)) for _, link := range settings.Links { link.Name = strings.TrimSpace(link.Name) link.URL = strings.TrimSpace(link.URL) if link.Name != "" && link.URL != "" { links = append(links, link) } } settings.Links = links repos := make([]RepoItem, 0, len(settings.GithubRepos)) for _, repo := range settings.GithubRepos { repo.URL = strings.TrimSpace(repo.URL) repo.Desc = strings.TrimSpace(repo.Desc) repo.Version = strings.TrimSpace(repo.Version) // 本地版本,可为空 if repo.Version == "" { repo.Version = AppVersion // 未填版本时自动用应用自身版本,用于分析并提示新版本 } if repo.URL != "" { repos = append(repos, repo) } } settings.GithubRepos = repos codes := make([]string, 0, len(settings.StockCodes)) for _, raw := range settings.StockCodes { code := normalizeStockCode(raw) // 补全 sh / sz / bj 前缀 if code != "" { codes = append(codes, code) } } settings.StockCodes = codes return settings } // normalizeTicker 校正滚动条配置:宽度、字号、速度下限,颜色默认值 func normalizeTicker(cfg TickerConfig, defaultColor string, defaultSpeed int) TickerConfig { if cfg.Width < 200 || cfg.Width > 6000 { cfg.Width = 0 // 宽度限定在 200~6000px,0 表示用默认 1400 } if cfg.FontSize < 10 || cfg.FontSize > 40 { cfg.FontSize = 14 // 字号限定在 10~40px } if strings.TrimSpace(cfg.Color) == "" { cfg.Color = defaultColor } if cfg.Speed < 20 || cfg.Speed > 400 { cfg.Speed = defaultSpeed // 速度限定在 20~400 px/s } return cfg } // isHexColor 判断是否为 #RGB / #RRGGBB 形式的颜色值 func isHexColor(value string) bool { value = strings.TrimSpace(value) if !strings.HasPrefix(value, "#") { return false } return len(value) == 4 || len(value) == 7 } // mergeTickerPositions 保留磁盘上已保存的滚动条位置: // 设置表单不提交位置,保存其他设置时不能覆盖拖动后记录的最新坐标 func mergeTickerPositions(settings AppSettings) AppSettings { prev := loadSettings() settings.StockTicker.X, settings.StockTicker.Y = prev.StockTicker.X, prev.StockTicker.Y settings.NewsTicker.X, settings.NewsTicker.Y = prev.NewsTicker.X, prev.NewsTicker.Y return settings } // applySettings 把配置应用到运行时:重新注册热键、设置窗口置顶、同步开机启动 func applySettings(settings AppSettings) { hotkeyMu.Lock() if settings.Hotkey != activeHotkey { if activeHotkey != "" { _ = app.GlobalShortcut.Unregister(activeHotkey) // 注销旧热键,避免冲突 } if err := app.GlobalShortcut.Register(settings.Hotkey, showMainWindow); err != nil { log.Printf("register hotkey %q failed: %v", settings.Hotkey, err) } else { activeHotkey = settings.Hotkey // 记录当前生效的热键 } } hotkeyMu.Unlock() if mainWindow != nil { mainWindow.SetAlwaysOnTop(settings.AlwaysOnTop) // 窗口置顶开关 } if settings.Autostart { _ = app.Autostart.Enable() // 写入系统开机启动项 } else { _ = app.Autostart.Disable() // 移除开机启动项 } ensureAPIServer(settings.APIAddr) // API 地址变化时重启内置服务 syncTickerWindows(settings) // 按配置创建 / 关闭 / 刷新滚动条窗口 }