settingsservice.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. // ============================================================================
  2. // settingsservice.go — 设置页后端能力绑定
  3. //
  4. // SettingsService 暴露给前端调用的核心服务:配置读写、新闻抓取、GitHub 监听、
  5. // 行情查询、翻译、二维码、截屏、Web 服务、待办读写、Markdown 渲染、API 状态等。
  6. // ============================================================================
  7. package main
  8. import (
  9. "encoding/base64"
  10. "fmt"
  11. "net/url"
  12. "os"
  13. "path/filepath"
  14. "strings"
  15. "time"
  16. "github.com/gomarkdown/markdown"
  17. "github.com/gomarkdown/markdown/html"
  18. "github.com/gomarkdown/markdown/parser"
  19. )
  20. // SettingsService 暴露「设置」页需要的后端能力给前端调用。
  21. // 前端通过 bindings 调用 GetSettings / SaveSettings / GetStocks 等方法。
  22. type SettingsService struct{}
  23. // AppVersion 返回应用版本号,供 footer 展示。
  24. // 版本常量在 settings.go 的 AppVersion(与 build/windows/info.json 的 ProductVersion 一致)。
  25. func (s *SettingsService) AppVersion() string {
  26. return AppVersion
  27. }
  28. // TTS 用微软 Edge 神经网络语音合成文本,返回 data URI(audio/mp3)。
  29. // 前端直接赋给 <audio src> 播放。voice 可选:xiaoxiao/yunxi/yunjian/xiaoyi/yunyang。
  30. // 古诗词朗读用云希(yunxi,沉稳男声)效果最佳。
  31. func (s *SettingsService) TTS(text, voice string) (string, error) {
  32. res, err := SpeakEdge(text, voice)
  33. if err != nil {
  34. return "", fmt.Errorf("语音合成失败: %w", err)
  35. }
  36. return "data:audio/mp3;base64," + base64.StdEncoding.EncodeToString(res.Audio), nil
  37. }
  38. // GetSettings 返回当前配置(读取配置文件,不存在则返回默认值)
  39. func (s *SettingsService) GetSettings() AppSettings {
  40. return loadSettings()
  41. }
  42. // SetMobileMode 前端在窗口尺寸变化时调用:移动端(窄屏)隐藏主机 Tab 并禁止滚动条
  43. func (s *SettingsService) SetMobileMode(mobile bool) {
  44. SetMobileMode(mobile)
  45. }
  46. // SaveSettings 保存配置并立即生效:热键、窗口置顶、开机启动、页面地址、股票代码等
  47. func (s *SettingsService) SaveSettings(settings AppSettings) (AppSettings, error) {
  48. settings = normalizeSettings(settings) // 校正空值与代码格式
  49. settings = mergeTickerPositions(settings) // 保留拖动保存的滚动条位置
  50. if err := saveSettings(settings); err != nil {
  51. return settings, err
  52. }
  53. applySettings(settings) // 应用到运行时
  54. return settings, nil
  55. }
  56. // GetStocks 按已保存的股票代码拉取最新行情
  57. func (s *SettingsService) GetStocks() ([]StockQuote, error) {
  58. return fetchStockQuotes(loadSettings().StockCodes)
  59. }
  60. // CaptureScreen 截取屏幕并返回 PNG 数据 URI,供「主机」页预览。
  61. // display 为显示器索引,-1 表示全部屏幕。
  62. func (s *SettingsService) CaptureScreen(display int) (string, error) {
  63. return captureScreen(display)
  64. }
  65. // PickWebDir 弹出目录选择对话框,返回选中的目录(取消返回空串)。
  66. func (s *SettingsService) PickWebDir() (string, error) {
  67. return pickWebDir()
  68. }
  69. // StartWebService 启动静态文件服务(默认页 index.html 或 readme.md)。
  70. func (s *SettingsService) StartWebService(dir string) (WebServiceInfo, error) {
  71. return StartWebService(dir)
  72. }
  73. // StopWebService 停止静态文件服务。
  74. func (s *SettingsService) StopWebService() {
  75. StopWebService()
  76. }
  77. // WebServiceStatus 返回静态文件服务当前状态。
  78. func (s *SettingsService) WebServiceStatus() WebServiceInfo {
  79. return WebServiceStatus()
  80. }
  81. // Translate 翻译文本:输入中文翻译为英文,输入英文翻译为中文(自动检测方向)。
  82. // 使用 MyMemory 免费 API,有每日配额限制。
  83. func (s *SettingsService) Translate(text string) (TranslateResult, error) {
  84. return translateMyMemory(text)
  85. }
  86. // GenerateQRCode 把输入字符串生成 PNG 二维码,返回 data URI 供前端展示。
  87. // size 为图片边长(像素),<=0 时用默认值 300。
  88. func (s *SettingsService) GenerateQRCode(text string, size int) (string, error) {
  89. return GenerateQRCode(text, size)
  90. }
  91. // GetMinuteChart 拉取指定股票的分时数据(点击股市行打开分时图时调用)
  92. func (s *SettingsService) GetMinuteChart(code string) (MinuteChart, error) {
  93. return fetchMinuteChart(normalizeStockCode(code))
  94. }
  95. // GetKLine 拉取指定股票的日 K 数据,count 为交易日数量
  96. func (s *SettingsService) GetKLine(code string, count int) ([]KLineBar, error) {
  97. return fetchKLine(normalizeStockCode(code), count)
  98. }
  99. // ShowWindow 供前端主动呼出窗口(例如通知点击后回到主界面)
  100. func (s *SettingsService) ShowWindow() {
  101. showMainWindow()
  102. }
  103. // GetNews 抓取新闻标题,按设置的关键词标记命中项并优先排序
  104. func (s *SettingsService) GetNews() ([]NewsItem, error) {
  105. return fetchNews(loadSettings().NewsKeywords)
  106. }
  107. // GetBiliDynamic 抓取 B站「关注的人最新动态」。
  108. // 需要设置里配置 B站登录 cookie(含 SESSDATA);cookie 为空或失效时回退热门视频。
  109. func (s *SettingsService) GetBiliDynamic() ([]BiliDynamicItem, error) {
  110. items, _, err := fetchBiliFeed(loadSettings().BiliCookie)
  111. return items, err
  112. }
  113. // GetGithubFeeds 抓取设置中所有 GitHub 仓库的最新提交
  114. func (s *SettingsService) GetGithubFeeds() []RepoFeed {
  115. return fetchGithubFeeds(loadSettings().GithubRepos)
  116. }
  117. // GetTrendingRepos 抓取 GitHub Trending 页的热门仓库列表(英文简介已自动翻译为中文)
  118. func (s *SettingsService) GetTrendingRepos() ([]TrendingRepo, error) {
  119. return fetchTrendingRepos()
  120. }
  121. // MarkGithubRepoChecked 标记某仓库已检视:把本地版本更新为最新 release 版本,消除「发现新版本」提示
  122. func (s *SettingsService) MarkGithubRepoChecked(url, latest string) error {
  123. settings := loadSettings()
  124. for i := range settings.GithubRepos {
  125. if settings.GithubRepos[i].URL == url {
  126. settings.GithubRepos[i].Version = latest
  127. break
  128. }
  129. }
  130. return saveSettings(settings)
  131. }
  132. // ReadTextFile 读取任意文本文件内容(供「自定义 Tab」文件类型页面使用)。
  133. // 文件不存在时返回空内容;其他错误原样返回。
  134. func (s *SettingsService) ReadTextFile(path string) (string, error) {
  135. data, err := os.ReadFile(path)
  136. if os.IsNotExist(err) {
  137. return "", nil // 文件尚未创建,返回空白
  138. }
  139. if err != nil {
  140. return "", err
  141. }
  142. return string(data), nil
  143. }
  144. // WriteTextFile 把文本内容写回指定文件(目录不存在时自动创建)。
  145. // 供文件类自定义 Tab 的「保存」使用。
  146. func (s *SettingsService) WriteTextFile(path, content string) error {
  147. if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
  148. return err
  149. }
  150. return os.WriteFile(path, []byte(content), 0o644)
  151. }
  152. // BackupFile 把指定文本文件按当前时间备份到配置的备份目录。
  153. // 备份文件名带时间戳:<原名>_YYYYMMDD_HHMMSS<扩展名>;目录不存在时自动创建。
  154. // 返回备份后的完整路径,供前端提示。
  155. func (s *SettingsService) BackupFile(path string) (string, error) {
  156. if _, err := os.Stat(path); err != nil {
  157. return "", err // 源文件不存在,无法备份
  158. }
  159. dir := strings.TrimSpace(loadSettings().BackupDir)
  160. if dir == "" {
  161. return "", fmt.Errorf("未设置「文本备份目录」,请到设置页配置后再备份")
  162. }
  163. if err := os.MkdirAll(dir, 0o755); err != nil {
  164. return "", err
  165. }
  166. ext := filepath.Ext(path)
  167. base := strings.TrimSuffix(filepath.Base(path), ext)
  168. stamp := time.Now().Format("20060102_150405")
  169. dst := filepath.Join(dir, fmt.Sprintf("%s_%s%s", base, stamp, ext))
  170. data, err := os.ReadFile(path)
  171. if err != nil {
  172. return "", err
  173. }
  174. if err := os.WriteFile(dst, data, 0o644); err != nil {
  175. return "", err
  176. }
  177. return dst, nil
  178. }
  179. // RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(供文件类自定义 Tab 展示)。
  180. // 使用 gomarkdown 渲染,并启用 CommonMark 扩展(表格、任务列表、围栏代码等)。
  181. func (s *SettingsService) RenderMarkdown(md string) string {
  182. p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
  183. renderer := html.NewRenderer(html.RendererOptions{
  184. Flags: html.CommonFlags | html.HrefTargetBlank, // 链接在新窗口打开
  185. })
  186. return string(markdown.ToHTML([]byte(md), p, renderer))
  187. }
  188. // ReadTodo 读取待办文件内容(每行一个任务);文件不存在时返回空
  189. func (s *SettingsService) ReadTodo() (string, error) {
  190. path := strings.TrimSpace(loadSettings().TodoPath)
  191. if path == "" {
  192. return "", fmt.Errorf("未设置「待办文件路径」,请到设置页配置后再使用")
  193. }
  194. data, err := os.ReadFile(path)
  195. if os.IsNotExist(err) {
  196. return "", nil // 首次使用:文件尚未创建,返回空白
  197. }
  198. if err != nil {
  199. return "", err
  200. }
  201. return string(data), nil
  202. }
  203. // WriteTodo 把待办内容写回待办文件(目录不存在时自动创建)
  204. func (s *SettingsService) WriteTodo(content string) error {
  205. path := strings.TrimSpace(loadSettings().TodoPath)
  206. if path == "" {
  207. return fmt.Errorf("未设置「待办文件路径」,请到设置页配置后再使用")
  208. }
  209. if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
  210. return err
  211. }
  212. return os.WriteFile(path, []byte(content), 0o644)
  213. }
  214. // PickUploadFile 弹出文件选择对话框,返回用户选中的本地文件路径(取消返回空串)。
  215. func (s *SettingsService) PickUploadFile() (string, error) {
  216. if app == nil {
  217. return "", fmt.Errorf("应用未初始化")
  218. }
  219. return app.Dialog.OpenFile().
  220. CanChooseDirectories(false).
  221. CanChooseFiles(true).
  222. SetTitle("选择要上传的文件").
  223. PromptForSingleSelection()
  224. }
  225. // UploadFile 供桌面端「服务」页直接上传本地文件(不走 HTTP、无需密码)。
  226. // 把本地文件压缩为 zip 存入上传目录(zip 内保留原文件名),返回下载信息。
  227. // 仅本机桌面调用,不暴露给外部网络。
  228. func (s *SettingsService) UploadFile(diskPath string) (map[string]interface{}, error) {
  229. diskPath = strings.TrimSpace(diskPath)
  230. if diskPath == "" {
  231. return nil, fmt.Errorf("未指定文件路径")
  232. }
  233. st, err := os.Stat(diskPath)
  234. if err != nil {
  235. return nil, fmt.Errorf("文件不存在: %w", err)
  236. }
  237. if st.IsDir() {
  238. return nil, fmt.Errorf("不能上传文件夹")
  239. }
  240. // 大小上限
  241. maxMB := loadSettings().UploadMaxSize
  242. if maxMB <= 0 {
  243. maxMB = 512
  244. }
  245. if st.Size() > int64(maxMB)<<20 {
  246. return nil, fmt.Errorf("文件过大,上限 %dMB", maxMB)
  247. }
  248. origBase := filepath.Base(strings.ReplaceAll(diskPath, "\\", "/"))
  249. if origBase == "." || origBase == "" {
  250. origBase = "upload.bin"
  251. }
  252. dir := uploadDirPath()
  253. if err := os.MkdirAll(dir, 0o755); err != nil {
  254. return nil, fmt.Errorf("无法创建上传目录: %w", err)
  255. }
  256. zipName := newStoredName(origBase + ".zip")
  257. zipPath := filepath.Join(dir, zipName)
  258. if err := zipFile(diskPath, zipPath, origBase); err != nil {
  259. os.Remove(zipPath)
  260. return nil, fmt.Errorf("压缩文件失败: %w", err)
  261. }
  262. savedName := filepath.Base(zipPath)
  263. downloadURL := "/api/download/" + url.PathEscape(savedName)
  264. fmt.Fprintf(apiLogRing, "[SYS] 桌面端上传并压缩: %s -> %s (%d 字节)\n", origBase, savedName, st.Size())
  265. return map[string]interface{}{
  266. "ok": true,
  267. "filename": origBase,
  268. "size": st.Size(),
  269. "downloadUrl": downloadURL,
  270. }, nil
  271. }
  272. // GetAPILogs 返回 Gin 日志环形缓冲的全部内容
  273. func (s *SettingsService) GetAPILogs() []string {
  274. return apiLogRing.Snapshot()
  275. }
  276. // ClearAPILogs 清空 Gin 日志缓冲
  277. func (s *SettingsService) ClearAPILogs() {
  278. apiLogRing.Clear()
  279. }
  280. // GetAPIStatus 返回内置 API 服务运行状态
  281. func (s *SettingsService) GetAPIStatus() APIStatus {
  282. return getAPIStatus()
  283. }
  284. // StopAPIService 停止内置 API 服务(服务页手动停止)
  285. func (s *SettingsService) StopAPIService() {
  286. StopAPIServer()
  287. }
  288. // StartAPIService 启动 / 重启内置 API 服务(服务页手动启动)
  289. func (s *SettingsService) StartAPIService() {
  290. StartAPIServer()
  291. }
  292. // GetPCStatus 返回电源控制命令状态
  293. func (s *SettingsService) GetPCStatus() PCStatus {
  294. return getPCStatus()
  295. }
  296. // SaveTickerPos 保存滚动条窗口拖动后的位置(只更新配置,不重建窗口)
  297. func (s *SettingsService) SaveTickerPos(kind string, x, y int) error {
  298. settings := loadSettings()
  299. if kind == "stock" {
  300. settings.StockTicker.X, settings.StockTicker.Y = x, y
  301. } else {
  302. settings.NewsTicker.X, settings.NewsTicker.Y = x, y
  303. }
  304. return saveSettings(settings) // 不调 applySettings,避免滚动条因保存位置而重建
  305. }
  306. // SaveTickerEnabled 保存滚动条开关状态(新闻条 / 股票条右侧「关闭」按钮调用)
  307. func (s *SettingsService) SaveTickerEnabled(kind string, enabled bool) error {
  308. settings := loadSettings()
  309. if kind == "stock" {
  310. settings.StockTicker.Enabled = enabled
  311. } else {
  312. settings.NewsTicker.Enabled = enabled
  313. }
  314. if err := saveSettings(settings); err != nil {
  315. return err
  316. }
  317. syncTickerWindows(settings) // 同步滚动条窗口:关闭时销毁
  318. return nil
  319. }