-
+日间主题
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 @@
+
+
+
+
+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
+
+
+
+
+
+
+