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())