diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b436f72..46b0e51 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -3,12 +3,12 @@ name: Pages # 官网(website/)发布:push 到 master 时组装并部署到 GitHub Pages。 # # 站点是零构建的静态页 —— 没有 npm 依赖、没有打包步骤,唯一的「构建」是把 -# public/screenshots 与图标复制进 website/assets/(见 scripts/site-assets.mjs)。 +# screenshots 与图标复制进 website/assets/(见 scripts/site-assets.mjs)。 # 那一步同时也是校验:页面上引用的每个本地资源都必须真的存在,否则这个 job 失败。 # 截图名写错在浏览器里只表现为一张破图,评审时看不出来,让它在这里断掉。 # -# paths 过滤器里必须带上 public/screenshots/** 与组装脚本本身:换一张截图同样 -# 应该重新发布,否则站点上还是旧图,而「只改了 public/」看起来与官网无关。 +# paths 过滤器里必须带上 screenshots/** 与组装脚本本身:换一张截图同样 +# 应该重新发布,否则站点上还是旧图,而「只改了 screenshots/」看起来与官网无关。 # docs/privacy.html 同理 —— 它是站点根上 privacy.html 的来源,而那个地址是 # Microsoft Store 登记的隐私政策 URL,改了不重新发布就会是旧的。 on: @@ -16,7 +16,7 @@ on: branches: [master] paths: - 'website/**' - - 'public/screenshots/**' + - 'screenshots/**' - 'public/qrcode.png' - 'src-tauri/icons/icon.png' - 'docs/privacy.html' @@ -25,7 +25,7 @@ on: pull_request: paths: - 'website/**' - - 'public/screenshots/**' + - 'screenshots/**' - 'public/qrcode.png' - 'src-tauri/icons/icon.png' - 'docs/privacy.html' diff --git a/.gitignore b/.gitignore index 5b57246..2c402ae 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ website/assets/ # Copied from docs/privacy.html so the Microsoft Store's privacy-policy URL # (…github.io/quant-desktop/privacy.html) survives the switch to Actions. website/privacy.html +# sitemap.xml 由 scripts/site-assets.mjs 按实际页面生成(含 git 取到的 lastmod), +# 手维护的话加了页面必然忘记登记。 +website/sitemap.xml # Editor directories and files .vscode/* diff --git a/CLAUDE.md b/CLAUDE.md index 34d7bce..09560e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,10 +228,19 @@ App.vue → NConfigProvider + NMessageProvider + NDialogProvider 三处刻意的设计: -- **素材不复制。** `public/screenshots/` 是唯一来源(README 也在用),`website/assets/` 由 [scripts/site-assets.mjs](scripts/site-assets.mjs) 复制生成,并加进 `.gitignore` —— 与 `dist/` 同级,都是产物。之所以要走脚本而不是直接放两份图:两份副本必然漂移,README 换了截图而官网上还是旧的。脚本在 CI 与本地是**同一条命令**(`npm run site:assets`),所以本地过了 CI 就会过。它顺带做一件 CI 才有意义的事:扫描 `index.html` 里每个本地 `src`/`href` 并断言文件存在 —— 截图名写错在浏览器里只是一张破图,评审时看不出来。 +- **素材不复制,而且不在 `public/` 下。** 截图住在仓库根的 `screenshots/`(README 也在用),`website/assets/` 由 [scripts/site-assets.mjs](scripts/site-assets.mjs) 复制生成,并加进 `.gitignore` —— 与 `dist/` 同级,都是产物。**位置这件事有个坑**:`public/` 是 Vite 的静态目录,会被整份复制进 `dist/`,而 `tauri.conf.json` 的 `frontendDist` 就是 `../dist` —— 截图原本放在 `public/screenshots/` 时,那 1.9MB 一直在被打进桌面安装包,而应用代码一处都没引用(`StatusBar.vue` 只引用了 `public/qrcode.png`,那是二维码的远程地址与兜底,**不能跟着搬**)。搬到仓库根之后 `dist/` 从约 3.3MB 降到 1.4MB。**改位置时必须同一个提交里改 README** —— 它由 github.com 按仓库相对路径渲染,文件一挪就全变破图。 + 脚本在 CI 与本地是**同一条命令**(`npm run site:assets`),所以本地过了 CI 就会过。它顺带做几件 CI 才有意义的事:扫描**每个**页面(不只 `index.html`)的 `src`/`href`/`srcset` 并断言文件存在 —— 截图名写错在浏览器里只是一张破图,评审时看不出来;以及**生成 `sitemap.xml`**(见下)。 - **配色与 `variables.css` 同源,且是仓库里唯一一处有意重复。** 站点没有构建步骤,就拿不到 `variables.css`;把调色板搬一份进 `website/styles.css` 是唯一的办法。所以**改那边的 surface / text / accent token 时,这里要跟着改**。站点在其上追加了一组**页面级氛围 token**(`--page-*` / `--grid-line` / `--hairline` / `--sheen` / `--glow-*` / `--btn-accent-*`),那批是站点自己的,应用里没有对应物:风格取向是深色金融终端 —— 有纵深的渐变底 + 蓝图网格、发丝描边 + 顶边 1px 高光、主按钮辉光。 - **主题契约与应用一致**:``,`` 里那段内联脚本在首帧前定好主题(否则深色访问者会先看到一帧白闪),`app.js` 只做增强 —— 禁用 JS 时页面依然完整可读,`.reveal` 的隐藏也由内联脚本加的 `.js` 类门控,不是无条件写死。`app.js` 里没有网络请求。 +**图片管线(`scripts/optimize-images.mjs`,`npm run site:images`)。** 截图原图是 2057px 的 PNG,一张 200–330KB;站点按 `` + WebP + `srcset` 三档(600 / 1100 / 2057)出,落地的通常是 44–68KB,降约 75–80%。同时出 favicon 32/180、品牌图标 128(原先 28px 的位置挂着 512px 的 203KB 原图)、以及 1200×630 的社交卡片。产物**提交进仓库**,所以 CI 不需要浏览器 —— 为十几张营销图给每次 `npm ci` 拉一个 `sharp` 那样的原生依赖不划算。代价是脚本本身依赖本机 Chrome(`CHROME_PATH` 可覆盖),**只有换截图时才需要手动跑**。三条实测得到的坑: + +- **`canvas.toDataURL` 在 `file://` 下会因 canvas 被 taint 而抛 SecurityError**,必须给 Chrome 加 `--allow-file-access-from-files`。 +- **目标宽 > 源宽时必须整个跳过,不能 `Math.min` 后仍按目标宽命名。** `settings-index.png` 只有 1298px 宽,早期实现产出的是 1298px 的图却叫 `-2057.webp`,而 HTML 里写着 `2057w` —— 描述符撒谎会让浏览器算出错误的密度。现在的规则是"文件名永远等于真实宽度",源图比所有目标都小的(行情条那两张 344×59)就不出 WebP,直接用 PNG。脚本每次运行会先清掉上一轮的 `.webp`,否则改了规则会留下没人引用却照样被发布的残留。 +- **`background-clip: text` 会静默吃掉溢出元素框的字形**(`scripts/og-card.html` 的字标就是被它截断的):渐变只画在框内,溢出部分没有背景可裁,于是 `color: transparent` 生效、字直接消失。配 `width: max-content` 让框包住文字。 + +功能图的 `` 外面套了一层壳,所以给 `picture` 加了 `display: contents` —— 否则它会成为 grid/flex 的子项,把 `.ticker-runway` 的 `place-items` 和功能图栅格的列宽都算错一层。 + 三条踩过坑的约束,改样式时别破坏: - **不引 webfont。** 通用设计建议会给 Inter + Google Fonts,但 `fonts.googleapis.com` 在国内基本加载不出来,而这个应用的用户主要在国内 —— 引它等于拿首屏去赌。科技感靠**刻意使用等宽栈**(`--font-mono`,应用本来就有这条栈)传达:区块编号、规格行、文件名、体积、表头。零网络依赖。 @@ -258,14 +267,14 @@ SEO 与 GEO(生成式引擎优化)都落在 `` 与 `website/` 根下 - **`application/ld+json` 是 GEO 的主要抓手。** 一个 `@graph` 装三个节点:`SoftwareApplication`(14 条 `featureList`、5 张 `screenshot`、`offers` 价格 0、`operatingSystem`、`license`)、`FAQPage`(页面上那四条问答)、`WebSite`。两条纪律:字段写**事实**不写形容词;`FAQPage` 的答案必须与**页面上看得见的正文**逐字对得上(结构化数据描述的内容页面没有,属于违规)。`scripts/` 之外有一个校验脚本会同时检查 JSON 能否解析、以及答案是否真的出现在正文里。 - **有意不写 `softwareVersion`。** 页面上没有任何静态版本号(顶栏那枚徽章是运行时拉 shields.io 的),写死一个数字只会在下次发版后变成假信息。 - **`og:image` 用 `main-light.png` 而不是 `main-dark.png`。** 缩略图尺寸下深色截图糊成一团,亮色的还能看清界面;同时要声明 `og:image:width/height`,宽高对不上会被判为无效图。 -- **`robots.txt` 把 AI 检索类爬虫(GPTBot / ClaudeBot / PerplexityBot / Google-Extended …)显式放行。** 不写也不会被默认拦(`User-agent: *` 已放行),显式列出的价值是这份文件同时是一份意图声明,将来要收紧只改这一处。`privacy.html` 带 `noindex`,因此**刻意不写进 sitemap**。 -- **`llms.txt`** 是给 AI 检索用的精简事实页(平台、体积、价格、许可、数据来源、功能清单、常见问答)。写它的时候注意把「商业使用需获授权」这条写清楚 —— PolyForm Noncommercial 不是 MIT,答案引擎漏掉这个限制会误导人。 +- **`robots.txt` 与 `llms.txt` 都不在本仓库里,唯一一份在 [Leaderxin/leaderxin.github.io](https://github.com/Leaderxin/leaderxin.github.io)。** 原因是协议层面的:robots 规定爬虫只读 `scheme://host/robots.txt`,llms.txt 的约定同理 —— 而项目站挂在 `/quant-desktop/` 下,放在那里的这两份文件**一次都不会被访问**(实测 `https://leaderxin.github.io/robots.txt` 曾是 404)。2026-10 建了用户站仓库把域名根补上,顺带也解决了根域名 404。那个仓库同时放 `index.html`(跳转到项目站,刻意 noindex)与根域的 `404.html`。**所以本站不再有这两份文件** —— 曾经有过,删掉是为了不出现两份必然漂移的副本。改 AI 爬虫放行名单、或改那些事实(平台/体积/价格/许可)时,去那个仓库改。 +- **`sitemap.xml` 由 `site-assets.mjs` 生成,不是手写的。** 它扫描 `website/*.html`、排除 `SITEMAP_SKIP`(`404.html` 是错误页、`privacy.html` 带 `noindex` —— 把 noindex 页列进 sitemap 会被 Search Console 报成错误),`lastmod` 取自 `git log` 而不是「现在」:每次部署都变的日期对爬虫是噪音。之所以要生成:手维护的 sitemap 在加页面时必然漏登记,而且**失败是静默的** —— 没有人类链接指向 sitemap 去看它。 改文案时记得回头看这几处:``、`meta description`、OG/Twitter 三件套、JSON-LD 里的 `description` 与 `featureList`、以及 `llms.txt`。它们不在一个文件里,最容易只改了一处。 注意 **`ticker-dark.png` / `ticker-light.png` 只有 344×59**,必须按原尺寸展示(`.ticker-runway` 就是为此存在的)—— 其余截图是 2000px 级,可自由缩放。 -工作流的 `paths` 过滤器里带着 `public/screenshots/**` 与 `docs/privacy.html`:换一张截图、改一次隐私政策,同样应该重新发布,否则站点上还是旧的,而「只改了 public/」看起来与官网无关。 +Pages 现在已经跑在 GitHub Actions 上(2026-10 从 `master:/docs` 迁过来的,见下)。工作流的 `paths` 过滤器里带着 `screenshots/**` 与 `docs/privacy.html`:换一张截图、改一次隐私政策,同样应该重新发布,否则站点上还是旧的,而「只改了 screenshots/」看起来与官网无关。 **从 `docs/` 迁过来这件事本身有个坑,别踩。** 迁之前 Pages 的源是 `master:/docs`(`build_type: legacy`),为的是 Microsoft Store 上架需要的隐私政策页(见 `183bba7 docs: 新增隐私政策页面(Microsoft Store 上架用)`)—— 也就是说 `https://leaderxin.github.io/quant-desktop/privacy.html` 是**商店登记过的地址**;同一个源顺带把 `docs/superpowers/` 下 20 份内部规划稿也公开了。而切到 GitHub Actions 后站点根变成 `website/`,那个地址会 404。所以 `scripts/site-assets.mjs` 会把 `docs/privacy.html` 复制到**站点根**(`website/privacy.html`,已 gitignore),与截图同一套「单一来源 + 脚本复制」的做法,两份不可能分家 —— **改隐私政策只改 `docs/privacy.html` 一处**。 diff --git a/README.md b/README.md index 8df24fd..8cb3574 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ [![Microsoft Store](https://img.shields.io/badge/Microsoft_Store-%E4%B8%8B%E8%BD%BD-00A4EF?style=for-the-badge)](https://apps.microsoft.com/detail/9P46MZZDTTZ0) [![加入交流群](https://img.shields.io/badge/%E5%8A%A0%E5%85%A5%E4%BA%A4%E6%B5%81%E7%BE%A4-%E5%BE%AE%E4%BF%A1-07c160?style=for-the-badge)](#-贡献与交流) -<img src="public/screenshots/main-dark.png" width="880" alt="QuantDesktop 主界面(夜间主题)" /> +<img src="screenshots/main-dark.png" width="880" alt="QuantDesktop 主界面(夜间主题)" /> <sub><b>主界面 · 夜间主题</b> —— 指数栏 · 涨跌概览 · 自选列表,一屏尽览</sub> @@ -57,7 +57,7 @@ - **涨跌速览** —— 上涨 / 下跌 / 平盘、涨停 / 跌停家数常驻主界面,市场情绪一眼看清 - **市场概览面板** —— 两市成交额、全市场涨跌分布直方图、行业与概念板块涨跌排行,热点在哪里一目了然 -<img src="public/screenshots/market-overview.png" width="880" alt="市场涨跌概览与板块排名" /> +<img src="screenshots/market-overview.png" width="880" alt="市场涨跌概览与板块排名" /> <sub><b>市场概览面板</b> —— 涨跌分布 · 两市成交额 · 行业 / 概念板块排行</sub> @@ -75,14 +75,14 @@ <tr> <td width="50%"> -<img src="public/screenshots/detail-minute-depth.png" alt="个股分时图与五档盘口" /> +<img src="screenshots/detail-minute-depth.png" alt="个股分时图与五档盘口" /> <sub><b>分时走势 + 五档盘口</b><br/>盘口每 3 秒静默刷新,附带开 / 高 / 低 / 量 / 额 / 换手率概要</sub> </td> <td width="50%"> -<img src="public/screenshots/detail-kline-sub.png" alt="个股分钟 K 线与副图指标" /> +<img src="screenshots/detail-kline-sub.png" alt="个股分钟 K 线与副图指标" /> <sub><b>分钟 K 线 + 副图指标</b><br/>主图 MA / BOLL 切换,副图成交量 / MACD 切换</sub> @@ -90,7 +90,7 @@ </tr> </table> -<img src="public/screenshots/index-kline.png" width="880" alt="指数 K 线详情" /> +<img src="screenshots/index-kline.png" width="880" alt="指数 K 线详情" /> <sub><b>指数详情</b> —— 上证指数日 K,均线与量能一应俱全</sub> @@ -103,7 +103,7 @@ - 置顶 / 上移 / 下移 / 删除,右键菜单快捷操作 - 跨数据源智能回退:一个源搜不到,自动换源再试 -<img src="public/screenshots/add-stock.png" width="880" alt="添加自选" /> +<img src="screenshots/add-stock.png" width="880" alt="添加自选" /> <sub><b>添加自选</b> —— 代码搜索 + 市场标签,同码指数与个股不再混淆</sub> @@ -124,14 +124,14 @@ <tr> <td align="center" width="50%"> -<img src="public/screenshots/ticker-dark.png" alt="悬浮行情条(夜间主题)" /> +<img src="screenshots/ticker-dark.png" alt="悬浮行情条(夜间主题)" /> <sub><b>夜间主题</b></sub> </td> <td align="center" width="50%"> -<img src="public/screenshots/ticker-light.png" alt="悬浮行情条(日间主题)" /> +<img src="screenshots/ticker-light.png" alt="悬浮行情条(日间主题)" /> <sub><b>日间主题</b></sub> @@ -160,14 +160,14 @@ <tr> <td width="50%"> -<img src="public/screenshots/settings-index.png" alt="设置页 · 指数区" /> +<img src="screenshots/settings-index.png" alt="设置页 · 指数区" /> <sub><b>指数区</b><br/>候选池勾选 + 拖拽排序,列表顺序就是顶栏从左到右的顺序</sub> </td> <td width="50%"> -<img src="public/screenshots/settings-ticker.png" alt="设置页 · 行情条" /> +<img src="screenshots/settings-ticker.png" alt="设置页 · 行情条" /> <sub><b>行情条</b><br/>显隐 / 透明背景 / 单次条数 / 轮播范围,改动即时预览</sub> @@ -185,14 +185,14 @@ <tr> <td width="50%"> -<img src="public/screenshots/main-dark.png" alt="主界面夜间主题" /> +<img src="screenshots/main-dark.png" alt="主界面夜间主题" /> <sub><b>夜间主题</b> —— 默认</sub> </td> <td width="50%"> -<img src="public/screenshots/main-light.png" alt="主界面日间主题" /> +<img src="screenshots/main-light.png" alt="主界面日间主题" /> <sub><b>日间主题</b></sub> diff --git a/package.json b/package.json index 0421dc9..91b9876 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest run", "test:watch": "vitest", "site:assets": "node scripts/site-assets.mjs", + "site:images": "node scripts/optimize-images.mjs", "tauri": "tauri", "tauri:build": "node scripts/build.mjs" }, diff --git a/screenshots/add-stock-1100.webp b/screenshots/add-stock-1100.webp new file mode 100644 index 0000000..eaeb9b1 Binary files /dev/null and b/screenshots/add-stock-1100.webp differ diff --git a/screenshots/add-stock-2057.webp b/screenshots/add-stock-2057.webp new file mode 100644 index 0000000..a498aa3 Binary files /dev/null and b/screenshots/add-stock-2057.webp differ diff --git a/screenshots/add-stock-600.webp b/screenshots/add-stock-600.webp new file mode 100644 index 0000000..b62e826 Binary files /dev/null and b/screenshots/add-stock-600.webp differ diff --git a/public/screenshots/add-stock.png b/screenshots/add-stock.png similarity index 100% rename from public/screenshots/add-stock.png rename to screenshots/add-stock.png diff --git a/screenshots/detail-kline-sub-1100.webp b/screenshots/detail-kline-sub-1100.webp new file mode 100644 index 0000000..8fc35be Binary files /dev/null and b/screenshots/detail-kline-sub-1100.webp differ diff --git a/screenshots/detail-kline-sub-2057.webp b/screenshots/detail-kline-sub-2057.webp new file mode 100644 index 0000000..dd5dad3 Binary files /dev/null and b/screenshots/detail-kline-sub-2057.webp differ diff --git a/screenshots/detail-kline-sub-600.webp b/screenshots/detail-kline-sub-600.webp new file mode 100644 index 0000000..5da6fd4 Binary files /dev/null and b/screenshots/detail-kline-sub-600.webp differ diff --git a/public/screenshots/detail-kline-sub.png b/screenshots/detail-kline-sub.png similarity index 100% rename from public/screenshots/detail-kline-sub.png rename to screenshots/detail-kline-sub.png diff --git a/screenshots/detail-minute-depth-1100.webp b/screenshots/detail-minute-depth-1100.webp new file mode 100644 index 0000000..ca54b96 Binary files /dev/null and b/screenshots/detail-minute-depth-1100.webp differ diff --git a/screenshots/detail-minute-depth-2057.webp b/screenshots/detail-minute-depth-2057.webp new file mode 100644 index 0000000..b754c8b Binary files /dev/null and b/screenshots/detail-minute-depth-2057.webp differ diff --git a/screenshots/detail-minute-depth-600.webp b/screenshots/detail-minute-depth-600.webp new file mode 100644 index 0000000..2b2ef70 Binary files /dev/null and b/screenshots/detail-minute-depth-600.webp differ diff --git a/public/screenshots/detail-minute-depth.png b/screenshots/detail-minute-depth.png similarity index 100% rename from public/screenshots/detail-minute-depth.png rename to screenshots/detail-minute-depth.png diff --git a/screenshots/index-kline-1100.webp b/screenshots/index-kline-1100.webp new file mode 100644 index 0000000..a3d649b Binary files /dev/null and b/screenshots/index-kline-1100.webp differ diff --git a/screenshots/index-kline-2057.webp b/screenshots/index-kline-2057.webp new file mode 100644 index 0000000..4fa4aeb Binary files /dev/null and b/screenshots/index-kline-2057.webp differ diff --git a/screenshots/index-kline-600.webp b/screenshots/index-kline-600.webp new file mode 100644 index 0000000..d14c4ed Binary files /dev/null and b/screenshots/index-kline-600.webp differ diff --git a/public/screenshots/index-kline.png b/screenshots/index-kline.png similarity index 100% rename from public/screenshots/index-kline.png rename to screenshots/index-kline.png diff --git a/screenshots/main-dark-1100.webp b/screenshots/main-dark-1100.webp new file mode 100644 index 0000000..046401a Binary files /dev/null and b/screenshots/main-dark-1100.webp differ diff --git a/screenshots/main-dark-2057.webp b/screenshots/main-dark-2057.webp new file mode 100644 index 0000000..f325560 Binary files /dev/null and b/screenshots/main-dark-2057.webp differ diff --git a/screenshots/main-dark-600.webp b/screenshots/main-dark-600.webp new file mode 100644 index 0000000..bffbabb Binary files /dev/null and b/screenshots/main-dark-600.webp differ diff --git a/public/screenshots/main-dark.png b/screenshots/main-dark.png similarity index 100% rename from public/screenshots/main-dark.png rename to screenshots/main-dark.png diff --git a/screenshots/main-light-1100.webp b/screenshots/main-light-1100.webp new file mode 100644 index 0000000..8749e7c Binary files /dev/null and b/screenshots/main-light-1100.webp differ diff --git a/screenshots/main-light-2057.webp b/screenshots/main-light-2057.webp new file mode 100644 index 0000000..9a53bcf Binary files /dev/null and b/screenshots/main-light-2057.webp differ diff --git a/screenshots/main-light-600.webp b/screenshots/main-light-600.webp new file mode 100644 index 0000000..162120c Binary files /dev/null and b/screenshots/main-light-600.webp differ diff --git a/public/screenshots/main-light.png b/screenshots/main-light.png similarity index 100% rename from public/screenshots/main-light.png rename to screenshots/main-light.png diff --git a/screenshots/market-overview-1100.webp b/screenshots/market-overview-1100.webp new file mode 100644 index 0000000..4588b5b Binary files /dev/null and b/screenshots/market-overview-1100.webp differ diff --git a/screenshots/market-overview-2057.webp b/screenshots/market-overview-2057.webp new file mode 100644 index 0000000..9643ada Binary files /dev/null and b/screenshots/market-overview-2057.webp differ diff --git a/screenshots/market-overview-600.webp b/screenshots/market-overview-600.webp new file mode 100644 index 0000000..8b14b10 Binary files /dev/null and b/screenshots/market-overview-600.webp differ diff --git a/public/screenshots/market-overview.png b/screenshots/market-overview.png similarity index 100% rename from public/screenshots/market-overview.png rename to screenshots/market-overview.png diff --git a/screenshots/settings-index-1100.webp b/screenshots/settings-index-1100.webp new file mode 100644 index 0000000..078a88f Binary files /dev/null and b/screenshots/settings-index-1100.webp differ diff --git a/screenshots/settings-index-600.webp b/screenshots/settings-index-600.webp new file mode 100644 index 0000000..78a4770 Binary files /dev/null and b/screenshots/settings-index-600.webp differ diff --git a/public/screenshots/settings-index.png b/screenshots/settings-index.png similarity index 100% rename from public/screenshots/settings-index.png rename to screenshots/settings-index.png diff --git a/screenshots/settings-ticker-1100.webp b/screenshots/settings-ticker-1100.webp new file mode 100644 index 0000000..afa5556 Binary files /dev/null and b/screenshots/settings-ticker-1100.webp differ diff --git a/screenshots/settings-ticker-600.webp b/screenshots/settings-ticker-600.webp new file mode 100644 index 0000000..1208bfd Binary files /dev/null and b/screenshots/settings-ticker-600.webp differ diff --git a/public/screenshots/settings-ticker.png b/screenshots/settings-ticker.png similarity index 100% rename from public/screenshots/settings-ticker.png rename to screenshots/settings-ticker.png diff --git a/public/screenshots/ticker-dark.png b/screenshots/ticker-dark.png similarity index 100% rename from public/screenshots/ticker-dark.png rename to screenshots/ticker-dark.png diff --git a/public/screenshots/ticker-light.png b/screenshots/ticker-light.png similarity index 100% rename from public/screenshots/ticker-light.png rename to screenshots/ticker-light.png diff --git a/scripts/og-card.html b/scripts/og-card.html new file mode 100644 index 0000000..1a6f0cf --- /dev/null +++ b/scripts/og-card.html @@ -0,0 +1,91 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> +<meta charset="UTF-8" /> +<title>QuantDesktop 社交卡片 + + + + +
+ +

QuantDesktop

+

让看盘更安静一点

+

A-SHARE / REAL-TIME / NATIVE DESKTOP / <5 MB

+
+ + + diff --git a/scripts/optimize-images.mjs b/scripts/optimize-images.mjs new file mode 100644 index 0000000..4262b00 --- /dev/null +++ b/scripts/optimize-images.mjs @@ -0,0 +1,209 @@ +// Image pipeline for the website. Run by hand when a screenshot changes; the +// output is committed, so the Pages workflow never needs a browser or a native +// image library — which is the point. The site is a zero-dependency static +// page, and pulling in `sharp` (a native binary) to serve a dozen marketing +// images would tax every `npm ci` in CI for it. +// +// Two jobs: +// 1. WebP derivatives at three widths, plus the favicon / brand-icon sizes — +// encoded by Chrome's own canvas (canvas.toDataURL), driven headlessly. +// 2. The 1200×630 social card — rendered from scripts/og-card.html, because +// that one is a design rather than a resize. +// +// Usage: node scripts/optimize-images.mjs +// Needs: Chrome (auto-detected, or set CHROME_PATH). + +import { readdir, writeFile, mkdir, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const run = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +const SRC_DIR = resolve(root, 'screenshots'); +const WEBSITE = resolve(root, 'website'); +const ICON = resolve(root, 'src-tauri/icons/icon.png'); +const CARD = resolve(__dirname, 'og-card.html'); +const TMP = resolve(root, '.image-tmp'); + +const posix = (p) => p.replace(/\\/g, '/'); + +// Three widths, one per display context (the sizes= attributes in index.html +// say which): ~600 for the two-up feature pairs, ~1100 for a single feature +// image and the carousel at 1x, 2057 for the carousel at 2x. +const WIDTHS = [600, 1100, 2057]; +const WEBP_QUALITY = 0.82; + +// Square derivatives of the app icon. 32/180 are the favicon and the iOS home +// screen icon; 128 serves the hero (92px) and the brand marks (28/30px) — those +// were pulling the 512px master, 203 KB for a 28px slot. +const ICON_SIZES = [ + ['favicon-32.png', 32], + ['favicon-180.png', 180], + ['icon-128.png', 128], +]; + +const CHROME_CANDIDATES = [ + process.env.CHROME_PATH, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', + '/usr/bin/google-chrome', + '/usr/bin/chromium-browser', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', +].filter(Boolean); + +const chromePath = CHROME_CANDIDATES.find((p) => existsSync(p)); +if (!chromePath) { + console.error('[img] Chrome not found. Set CHROME_PATH to its executable.'); + process.exit(1); +} + +function withChrome(args) { + return run(chromePath, [ + '--headless=new', + '--disable-gpu', + '--hide-scrollbars', + '--no-first-run', + // Without this, every file:// document is its own origin: drawing a + // file:// image onto a canvas taints it and toDataURL throws. + '--allow-file-access-from-files', + `--user-data-dir=${posix(join(TMP, 'profile'))}`, + ...args, + ], { maxBuffer: 256 * 1024 * 1024 }); +} + +// Every derivative comes out of a single Chrome run: one page loads each source +// into an Image, draws it to a canvas at the target size, and hands back data +// URLs as JSON in the DOM. `--dump-dom` gives us that back without needing a +// devtools client (Node 20 here has no global WebSocket). +async function encodeDerivatives(sources) { + const cfg = { + sources: sources.map(posix), + widths: WIDTHS, + quality: WEBP_QUALITY, + icons: ICON_SIZES, + icon: posix(ICON), + }; + + const page = ``; + + await writeFile(join(TMP, 'encode.html'), page); + const { stdout } = await withChrome([ + '--virtual-time-budget=180000', + '--dump-dom', + `file:///${posix(join(TMP, 'encode.html'))}`, + ]); + + const m = /
([\s\S]*?)<\/pre>/.exec(stdout);
+  if (!m) throw new Error('encoder produced no output (Chrome failed?)');
+  const json = m[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+  const out = JSON.parse(json);
+  if (out.__error) throw new Error(out.__error);
+  return out;
+}
+
+function decode(dataUrl) {
+  return Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
+}
+
+async function main() {
+  const pngs = (await readdir(SRC_DIR)).filter((f) => f.endsWith('.png')).sort();
+  if (pngs.length === 0) throw new Error(`no PNGs in ${SRC_DIR}`);
+
+  await rm(TMP, { recursive: true, force: true });
+  await mkdir(TMP, { recursive: true });
+
+  // Clear the previous run's derivatives first: the width rule can change, and
+  // a leftover file is never referenced but still gets copied into the site and
+  // committed. (The PNGs are sources and are left alone.)
+  for (const f of await readdir(SRC_DIR)) {
+    if (f.endsWith('.webp')) await rm(join(SRC_DIR, f), { force: true });
+  }
+
+  console.log(`[img] encoding ${pngs.length} screenshots at ${WIDTHS.join('/')}px …`);
+  const out = await encodeDerivatives(pngs.map((f) => join(SRC_DIR, f)));
+
+  let shots = 0;
+  let icons = 0;
+  for (const [key, dataUrl] of Object.entries(out)) {
+    const [kind, a, b] = key.split('|');
+    if (kind === 'shot') {
+      await writeFile(join(SRC_DIR, `${a}-${b}.webp`), decode(dataUrl));
+      shots += 1;
+    } else {
+      // Favicons and the small brand icon are site source, not derivatives of a
+      // screenshot, so they live in website/ and are published as-is.
+      await writeFile(join(WEBSITE, a), decode(dataUrl));
+      icons += 1;
+    }
+  }
+  console.log(`[img] screenshots/: ${shots} WebP derivatives`);
+  console.log(`[img] website/: ${icons} icon sizes`);
+
+  await withChrome([
+    '--force-device-scale-factor=1',
+    '--window-size=1200,630',
+    '--virtual-time-budget=10000',
+    `--screenshot=${posix(join(WEBSITE, 'og-cover.png'))}`,
+    `file:///${posix(CARD)}`,
+  ]);
+  console.log('[img] website/og-cover.png (1200×630)');
+
+  await rm(TMP, { recursive: true, force: true });
+  console.log('[img] done — commit the output.');
+}
+
+main().catch(async (err) => {
+  console.error(`[img] failed: ${err.message}`);
+  await rm(TMP, { recursive: true, force: true }).catch(() => {});
+  process.exit(1);
+});
diff --git a/scripts/site-assets.mjs b/scripts/site-assets.mjs
index e8a0fba..9612a85 100644
--- a/scripts/site-assets.mjs
+++ b/scripts/site-assets.mjs
@@ -3,7 +3,7 @@
 //
 // Why assemble at all: website/ is published to GitHub Pages as a self-contained
 // directory, so its images must live inside it. Those images are already tracked
-// under public/screenshots/ (the README uses them), and a second committed copy
+// under screenshots/ (the README uses them), and a second committed copy
 // would drift the moment either side is updated. So they are copied here into
 // website/assets/ — a build product, gitignored like dist/.
 //
@@ -13,8 +13,9 @@
 // Node alone, no dependencies: this runs both locally (`npm run site:assets`
 // before opening website/index.html) and in .github/workflows/pages.yml.
 
-import { cp, mkdir, readdir, readFile, rm } from 'node:fs/promises';
+import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
 import { existsSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
 import { resolve, dirname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
@@ -37,8 +38,15 @@ const FILES = [
 // the single source and this is a copy, so the two can never disagree.
 const ROOT_FILES = [['docs/privacy.html', 'privacy.html']];
 
-const SCREENSHOT_SRC = 'public/screenshots';
+const SCREENSHOT_SRC = 'screenshots';
 const ENTRY = 'index.html';
+const SITE_URL = 'https://leaderxin.github.io/quant-desktop/';
+
+// Pages that must not appear in the sitemap. 404.html is an error page, and
+// privacy.html carries `noindex` — Search Console reports a noindex URL that is
+// also listed in a sitemap as an error, so the two are mutually exclusive by
+// design, not by oversight.
+const SITEMAP_SKIP = new Set(['404.html', 'privacy.html']);
 
 async function assemble(missing) {
   // Rebuild from scratch: a screenshot deleted upstream must not linger here
@@ -88,24 +96,85 @@ async function assemble(missing) {
   return copied;
 }
 
-// Every src/href in the page that points at something in this repo must exist.
-// Absolute URLs (http:, //, mailto:, data:) and pure fragments are none of our
-// business — they can rot without breaking the build.
+// Every src/href/srcset in every published page that points at something in
+// this repo must exist. Absolute URLs (http:, //, /path, mailto:, data:) and
+// pure fragments are none of our business — they can rot without breaking the
+// build. The `/` case matters: 404.html has to use site-absolute paths, because
+// a project site serves it for missing paths at *any* depth, where a relative
+// link would resolve against the broken URL instead of the site root.
 function isLocalRef(ref) {
-  return !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(ref);
+  return !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(ref);
+}
+
+async function publishedPages() {
+  return (await readdir(siteDir)).filter((f) => f.endsWith('.html')).sort();
 }
 
-async function verifyReferences() {
-  const html = await readFile(resolve(siteDir, ENTRY), 'utf8');
+async function verifyReferences(pages) {
   const broken = [];
 
-  for (const [, ref] of html.matchAll(/(?:src|href)="([^"]+)"/g)) {
-    const clean = ref.split(/[?#]/)[0];
-    if (!clean || !isLocalRef(clean)) continue;
-    if (!existsSync(resolve(siteDir, clean))) broken.push(ref);
+  for (const page of pages) {
+    const html = await readFile(resolve(siteDir, page), 'utf8');
+    const seen = new Set();
+
+    const check = (ref) => {
+      const clean = ref.split(/[?#]/)[0];
+      if (!clean || !isLocalRef(clean) || seen.has(clean)) return;
+      seen.add(clean);
+      if (!existsSync(resolve(siteDir, clean))) broken.push(`${page} → ${clean}`);
+    };
+
+    for (const [, ref] of html.matchAll(/(?:src|href)="([^"]+)"/g)) check(ref);
+
+    // srcset is a comma-separated list of "url descriptor" pairs. A stale entry
+    // here is exactly as invisible as a stale src — the browser just falls back
+    // to a smaller candidate — so it is worth catching.
+    for (const [, list] of html.matchAll(/srcset="([^"]+)"/g)) {
+      for (const part of list.split(',')) {
+        const url = part.trim().split(/\s+/)[0];
+        if (url) check(url);
+      }
+    }
   }
 
-  return [...new Set(broken)];
+  return broken;
+}
+
+// lastmod comes from git, not from "now": a date that changes on every deploy
+// is noise to a crawler and teaches it to distrust the field.
+function lastModified(page) {
+  try {
+    const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', `website/${page}`], {
+      cwd: root,
+      encoding: 'utf8',
+      stdio: ['ignore', 'pipe', 'ignore'],
+    });
+    return out.trim() || null;
+  } catch {
+    return null; // not a git checkout, or a shallow clone with no history
+  }
+}
+
+// Generated rather than hand-written: the moment a new page is added by hand,
+// a hand-maintained sitemap starts drifting — and the failure is silent, since
+// nothing links to the sitemap for a human to notice.
+async function writeSitemap(pages) {
+  const listed = pages.filter((p) => !SITEMAP_SKIP.has(p));
+  const urls = listed.map((page) => {
+    const loc = SITE_URL + (page === ENTRY ? '' : page);
+    const stamp = lastModified(page);
+    return `  \n    ${loc}${stamp ? `\n    ${stamp}` : ''}\n  `;
+  });
+
+  await writeFile(
+    resolve(siteDir, 'sitemap.xml'),
+    '\n' +
+      '\n' +
+      urls.join('\n') +
+      '\n\n'
+  );
+
+  return listed;
 }
 
 async function main() {
@@ -117,15 +186,20 @@ async function main() {
     process.exit(1);
   }
 
-  const broken = await verifyReferences();
+  const pages = await publishedPages();
+
+  const broken = await verifyReferences(pages);
   if (broken.length > 0) {
-    console.error(`[site] ${ENTRY} references files that do not exist:`);
+    console.error('[site] These references do not resolve:');
     for (const ref of broken) console.error(`  - ${ref}`);
     process.exit(1);
   }
 
+  const listed = await writeSitemap(pages);
+
   console.log(`[site] Copied ${copied} files into website/assets/`);
-  console.log(`[site] All local references in ${ENTRY} resolve.`);
+  console.log(`[site] ${pages.length} page(s): every local reference resolves.`);
+  console.log(`[site] sitemap.xml lists ${listed.length} URL(s).`);
   console.log('[site] Open website/index.html in a browser to preview.');
 }
 
diff --git a/website/404.html b/website/404.html
new file mode 100644
index 0000000..bf22323
--- /dev/null
+++ b/website/404.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+页面不存在 · QuantDesktop
+
+
+
+
+
+
+  
+

404 / NOT FOUND

+

这个地址没有内容

+

你要找的页面不存在,或者链接已经失效。

+ 返回 QuantDesktop 官网 +

+ 也可以直接去 + GitHub 仓库 + 或 + 下载最新版。 +

+
+ + diff --git a/website/favicon-180.png b/website/favicon-180.png new file mode 100644 index 0000000..f78ae3b Binary files /dev/null and b/website/favicon-180.png differ diff --git a/website/favicon-32.png b/website/favicon-32.png new file mode 100644 index 0000000..0cfdbf3 Binary files /dev/null and b/website/favicon-32.png differ diff --git a/website/icon-128.png b/website/icon-128.png new file mode 100644 index 0000000..f844490 Binary files /dev/null and b/website/icon-128.png differ diff --git a/website/index.html b/website/index.html index f78c0cb..802869b 100644 --- a/website/index.html +++ b/website/index.html @@ -4,13 +4,15 @@ QuantDesktop —— 免费开源的 A 股桌面看盘工具 - + - - + + + @@ -18,15 +20,17 @@ - - - - - + + + + + - + +
夜间主题 - 悬浮行情条夜间主题,显示三只自选股的名称、现价与涨跌幅 + 悬浮行情条夜间主题,显示三只自选股的名称、现价与涨跌幅 日间主题 - 悬浮行情条日间主题,显示三只自选股的名称、现价与涨跌幅 + 悬浮行情条日间主题,显示三只自选股的名称、现价与涨跌幅
@@ -486,7 +545,12 @@

分组、列、排序,都按你的习惯来

- 添加自选对话框:输入 6 位代码搜索,结果带沪A、ETF 等市场标签 + + + 添加自选对话框:输入 6 位代码搜索,结果带沪A、ETF 等市场标签 +
代码搜索 + 市场标签,同码指数与个股不再混淆
@@ -509,11 +573,23 @@

把界面调成你的样子

- 设置页指数区:14 个候选指数勾选与拖拽排序 + + + 设置页指数区:14 个候选指数勾选与拖拽排序 +
指数区
- 设置页行情条分区:显隐、透明背景、单次条数与轮播范围 + + + 设置页行情条分区:显隐、透明背景、单次条数与轮播范围 +
行情条
@@ -534,11 +610,21 @@

深浅双主题,涨跌配色也能换

- 主界面夜间主题 + + + 主界面夜间主题 +
夜间主题(默认)
- 主界面日间主题 + + + 主界面日间主题 +
日间主题
@@ -761,7 +847,7 @@

你可能想问的