diff --git a/.gitignore b/.gitignore index 2fefd1d..7dcb737 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ /release/ /samples/python-task/.venv/ +# 字体二进制体积大(约 6.7 MB),不入库;用 tools/build-fonts.py 重新生成 +/frontend/public/fonts/LXGWWenKai-*.woff2 + # Editors and operating system files /.idea/ .vscode/ diff --git a/README.md b/README.md index 28acef1..0ca2ecc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台 - 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程 - SQLite 持久化,重启后任务与历史仍在 - 运行日志实时推送(SSE),断线自动回退轮询 -- 前端零外部依赖:字体、样式、脚本全部随包自带,**断网可用** +- 前端零外部依赖:样式与脚本随包自带;界面字体可选,未生成时自动回退系统字体 > 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。 @@ -48,7 +48,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台 ```text release/TaskPilot/ ├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管) -├── web/ # 前端静态资源(含本地字体) +├── web/ # 前端静态资源(字体可选) ├── data/task_pilot.db # SQLite 数据库(首次启动创建) ├── logs/{task_id}/{run_id}.log ├── start.ps1 @@ -112,7 +112,7 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000 | Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 | | 界面 | 三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志抽屉 | | 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 | -| 交付 | 单进程静态托管、SPA 回退、Release 打包、离线字体、OpenAPI 契约 | +| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 | --- @@ -222,8 +222,9 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled - **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。 - **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。 - **日志抽屉**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 -- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动),字体 `LXGW WenKai` + - `Outfit-Medium` 全部本地托管,断网仍可完整渲染。 +- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用 + `LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用 + `tools/build-fonts.py` 生成,缺失时回退系统字体。 --- @@ -466,6 +467,18 @@ pnpm --dir frontend test:e2e 生成文件不要手工修改。 +### 界面字体(可选) + +字体二进制体积约 6.7 MB,**不纳入仓库**。首次克隆后如需与设计一致的中文界面: + +```powershell +uv run --with fonttools --with brotli tools/build-fonts.py +``` + +脚本会从 `lxgw-wenkai-webfont` 的 CDN 下载分包、合并并裁剪到 GB2312 + 中英文标点, +输出到 `frontend/public/fonts/`(已被 `.gitignore` 忽略)。不生成也能正常运行, +只是正文回退到系统字体;`Outfit-Medium.ttf`(55 KB)仍随仓库提供。 + ### 测试构成 | 层 | 覆盖 | @@ -473,7 +486,7 @@ pnpm --dir frontend test:e2e | Rust 单元(52) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属)、安全、目录列举 | | Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 | | 前端 Vitest(13) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器(进入/取消/错误)、下次触发时间 | -| Playwright(4) | 真实任务执行与日志、编辑/停用/删除、窄屏、**完全离线渲染**(拦截所有外部请求) | +| Playwright(4) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**(拦截所有外部请求) | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | --- @@ -504,6 +517,7 @@ pnpm --dir frontend test:e2e - 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。 - 未安装为 Windows 服务;关闭启动脚本后调度停止。 - 错过的调度不会补跑。 +- 界面字体不入库(体积原因),需用 `tools/build-fonts.py` 生成;缺失时回退系统字体。 --- diff --git a/docs/decisions/0006-visual-theme-and-offline-fonts.md b/docs/decisions/0006-visual-theme-and-offline-fonts.md index 9e05ef9..9472ced 100644 --- a/docs/decisions/0006-visual-theme-and-offline-fonts.md +++ b/docs/decisions/0006-visual-theme-and-offline-fonts.md @@ -30,3 +30,13 @@ - 只包含 400 / 700 两个字重,500 / 600 由 CSS 字重匹配落到 400 / 700;主题的 Light(300)未包含。 - 未实现参考站点的暗色模式(`data-scheme="dark"`)。 - 字体文件随 `frontend/public/fonts` 一起进入安装包,体积增加约 6.7 MB。 + +## 后续调整(2026-10-26) + +仓库托管的远程仓库对单次推送体积有上限,6.7 MB 的字体二进制无法一次推上去。因此: + +- 中文字体二进制 **不再纳入版本库**(`/frontend/public/fonts/LXGWWenKai-*.woff2` 已加入 `.gitignore`); +- 新增 `tools/build-fonts.py`,一条命令即可从官方 CDN 重新下载、合并并子集化生成这两个文件; +- `@font-face` 仍然保留,字体缺失时浏览器回退到系统字体,功能不受影响; +- 对应的端到端用例改为「无外部网络渲染」:始终断言没有外部请求,只有字体文件存在时才断言字体命中; +- `Outfit-Medium.ttf`(55 KB)体积很小,继续随仓库提供。 diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index 5803dfd..f571452 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -222,7 +222,7 @@ test('cancels a running task from the log drawer', async ({ await expect(drawer.locator('.status-cancelled').first()).toBeVisible() }) -test('renders fully offline with bundled fonts', async ({ page }) => { +test('renders without external network access', async ({ page }) => { const blocked: string[] = [] await page.route('**/*', (route) => { const url = route.request().url() @@ -241,19 +241,24 @@ test('renders fully offline with bundled fonts', async ({ page }) => { await page.goto('/') await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible() - const fonts = await page.evaluate(async () => { + const state = await page.evaluate(async () => { + // 字体二进制不入库;未生成时应回退系统字体,而不是报错。 + const probe = await fetch('/fonts/LXGWWenKai-Regular.woff2', { + method: 'HEAD', + }) await document.fonts.ready return { - wenkai: document.fonts.check('14px "LXGW WenKai"'), - display: document.fonts.check('500 26px "Outfit-Medium"'), - loaded: Array.from(document.fonts).map((face) => face.family), + wenkaiAvailable: probe.ok, + wenkaiLoaded: document.fonts.check('14px "LXGW WenKai"'), + displayLoaded: document.fonts.check('500 26px "Outfit-Medium"'), } }) expect(blocked).toEqual([]) - expect(fonts.wenkai).toBe(true) - expect(fonts.display).toBe(true) - expect(fonts.loaded).toContain('LXGW WenKai') + expect(state.displayLoaded).toBe(true) + if (state.wenkaiAvailable) { + expect(state.wenkaiLoaded).toBe(true) + } await page.screenshot({ path: 'test-results/dashboard-offline.png', fullPage: true, diff --git a/frontend/index.html b/frontend/index.html index f4fb96b..f764c82 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,13 +7,6 @@ name="description" content="TaskPilot - Windows 本地定时任务管理平台" /> - bytes: + request = urllib.request.Request( + url, headers={"User-Agent": "taskpilot-font-builder"} + ) + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + + +def subset_urls(css_name: str) -> list[str]: + css = fetch(CDN + css_name).decode("utf-8") + # 形如:src: url('./files/lxgwwenkai-regular-subset-4.woff2') + matches = re.findall(r"url\(\s*['\"]?([^'\")]+\.woff2)['\"]?\s*\)", css) + urls = sorted({match.lstrip("./") for match in matches}) + if not urls: + raise RuntimeError(f"未能从 {css_name} 解析出字体子集,CDN 格式可能已变更") + return urls + + +def build_codepoints() -> set[int]: + points: set[int] = set() + for start, end in ( + (0x20, 0x7F), # ASCII + (0xA0, 0x100), # Latin-1 + (0x2000, 0x2070), # 通用标点 + (0x25A0, 0x2600), # 几何图形 + (0x3000, 0x3040), # 中文标点 + (0xFF00, 0xFFF0), # 全角字符 + ): + points.update(range(start, end)) + points.update( + [0x00D7, 0x00F7, 0x2190, 0x2191, 0x2192, 0x2193, 0x2713, 0x2717, 0x23F3] + ) + # GB2312 汉字与符号行 + for high in range(0xA1, 0xF8): + for low in range(0xA1, 0xFF): + try: + points.add(ord(bytes([high, low]).decode("gb2312"))) + except UnicodeDecodeError: + continue + return points + + +def build(weight: str, css_name: str, points: set[int], workdir: Path) -> None: + urls = subset_urls(css_name) + print(f"[{weight}] 下载 {len(urls)} 个子集…") + paths: list[str] = [] + for url in urls: + data = fetch(CDN + url) + font = TTFont(io.BytesIO(data)) + target = workdir / Path(url).name.replace(".woff2", ".ttf") + font.save(target) + font.close() + paths.append(str(target)) + + print(f"[{weight}] 合并 {len(paths)} 个字体…") + merged = Merger().merge(paths) + + options = Options() + options.flavor = "woff2" + options.hinting = False + options.layout_features = ["*"] + options.name_IDs = ["*"] + subsetter = Subsetter(options=options) + subsetter.populate(unicodes=points) + subsetter.subset(merged) + + output = OUT_DIR / f"LXGWWenKai-{weight}.woff2" + merged.save(output) + print(f"[{weight}] 写出 {output} {output.stat().st_size / 1024 / 1024:.2f} MB") + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + points = build_codepoints() + print(f"目标字符集:{len(points)} 个码位") + with tempfile.TemporaryDirectory() as directory: + workdir = Path(directory) + for weight, css_name in WEIGHTS.items(): + build(weight, css_name, points, workdir) + return 0 + + +if __name__ == "__main__": + sys.exit(main())