chore: 迁移到 pnpm 并更新打包与测试文档

- 用 pnpm 替换 npm,锁定 pnpm@10.33.0,删除 package-lock.json 相关说明
- package.json 将渲染层依赖移入 devDependencies,减小 app.asar 体积
- 新增 electron:smoke 冒烟脚本与 icon 生成脚本
- win 平台设置 AppUserModelID,修复任务栏图标归组
- 清理 tsBuildInfoFile 输出路径至 node_modules/.tmp
- 更新测试断言以匹配画幅遮罩、取景器缩放与模型库外部资产行为
- README 与打包指南补充 pnpm、镜像与常见问题说明
This commit is contained in:
2026-09-16 20:03:57 +08:00
parent 2ad176be4e
commit b9fd289caf
27 changed files with 5538 additions and 8452 deletions

136
scripts/generate_icon.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""生成应用图标 public/icon.png(512x512)。
仓库原先引用了 public/icon.png 但文件缺失,导致 electron-builder 回落到默认
Electron 图标。本脚本零第三方依赖(仅标准库 zlib/struct),可重复生成:
python scripts/generate_icon.py
图形语义:视口取景框(四角括号)+ 播放三角,对应"预演/播放分镜"。
"""
from __future__ import annotations
import struct
import zlib
from pathlib import Path
SIZE = 512
SS = 3 # 超采样倍率,用于边缘抗锯齿
W = SIZE * SS
# 取自 src/styles/index.css 的设计令牌
BG_TOP = (22, 24, 28)
BG_BOTTOM = (9, 9, 9)
BRACKET = (242, 244, 246)
ACCENT = (3, 150, 255)
def rounded_rect_coverage(px: float, py: float, x0: float, y0: float, x1: float, y1: float, radius: float) -> bool:
if not (x0 <= px <= x1 and y0 <= py <= y1):
return False
cx = min(max(px, x0 + radius), x1 - radius)
cy = min(max(py, y0 + radius), y1 - radius)
dx = px - cx
dy = py - cy
return dx * dx + dy * dy <= radius * radius
def bracket_coverage(px: float, py: float, inset: float, arm: float, thickness: float) -> bool:
"""四角取景框括号:每角由一条横臂和一条竖臂组成。"""
lo = inset
hi = SIZE - inset
horizontal_arm = (lo <= px <= lo + arm) or (hi - arm <= px <= hi)
vertical_arm = (lo <= py <= lo + arm) or (hi - arm <= py <= hi)
horizontal_bar = (lo <= py <= lo + thickness) or (hi - thickness <= py <= hi)
vertical_bar = (lo <= px <= lo + thickness) or (hi - thickness <= px <= hi)
return (horizontal_arm and horizontal_bar) or (vertical_arm and vertical_bar)
def triangle_coverage(px: float, py: float) -> bool:
ax, ay = 196.0, 168.0
bx, by = 196.0, 344.0
cx, cy = 356.0, 256.0
d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by)
d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy)
d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay)
has_neg = d1 < 0 or d2 < 0 or d3 < 0
has_pos = d1 > 0 or d2 > 0 or d3 > 0
return not (has_neg and has_pos)
def build_pixels() -> bytearray:
samples = SS * SS
pixels = bytearray(SIZE * SIZE * 4)
for oy in range(SIZE):
for ox in range(SIZE):
bg_hits = 0
bracket_hits = 0
triangle_hits = 0
for sy in range(SS):
for sx in range(SS):
px = ox + (sx + 0.5) / SS
py = oy + (sy + 0.5) / SS
if rounded_rect_coverage(px, py, 0.0, 0.0, float(SIZE), float(SIZE), 118.0):
bg_hits += 1
if bracket_coverage(px, py, inset=92.0, arm=104.0, thickness=26.0):
bracket_hits += 1
if triangle_coverage(px, py):
triangle_hits += 1
alpha = bg_hits / samples
if alpha <= 0:
continue
ratio = oy / (SIZE - 1)
base = tuple(
BG_TOP[channel] + (BG_BOTTOM[channel] - BG_TOP[channel]) * ratio for channel in range(3)
)
accent_cov = (bracket_hits + triangle_hits) / samples
if accent_cov > 0:
layer = BRACKET if bracket_hits >= triangle_hits else ACCENT
layer_alpha = min(1.0, accent_cov / max(alpha, 1e-6))
base = tuple(
base[channel] + (layer[channel] - base[channel]) * layer_alpha for channel in range(3)
)
offset = (oy * SIZE + ox) * 4
pixels[offset] = int(round(base[0]))
pixels[offset + 1] = int(round(base[1]))
pixels[offset + 2] = int(round(base[2]))
pixels[offset + 3] = int(round(alpha * 255))
return pixels
def write_png(path: Path, pixels: bytearray) -> None:
raw = bytearray()
stride = SIZE * 4
for y in range(SIZE):
raw.append(0) # filter type 0
raw.extend(pixels[y * stride : (y + 1) * stride])
def chunk(tag: bytes, payload: bytes) -> bytes:
return (
struct.pack(">I", len(payload))
+ tag
+ payload
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)
)
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(bytes(raw), 9))
png += chunk(b"IEND", b"")
path.write_bytes(png)
def main() -> None:
target = Path(__file__).resolve().parent.parent / "public" / "icon.png"
target.parent.mkdir(parents=True, exist_ok=True)
write_png(target, build_pixels())
print(f"wrote {target} ({target.stat().st_size} bytes, {SIZE}x{SIZE})")
if __name__ == "__main__":
main()

105
scripts/test-electron.cjs Normal file
View File

@@ -0,0 +1,105 @@
// scripts/test-electron.cjs
// 冒烟测试:确认构建产物 dist/index.html 能被 Electron 正常加载,且渲染层没有报错。
//
// 用法:pnpm run electron:smoke
// 注意:package.json 中有 "type": "module",因此本文件必须使用 .cjs 后缀,
// 否则 require 会因被当作 ESM 而报错。
//
// 这个脚本也用来验证「dist 是自包含的」:渲染层需要的所有依赖都已被 Vite
// 打包进 dist/assets,因此打包时不需要把 node_modules 塞进 app.asar。
// 有的终端 / CI 会注入 ELECTRON_RUN_AS_NODE=1,那会让 Electron 退化成纯 Node,
// require('electron') 只返回二进制路径,后面会以 "Cannot read properties of
// undefined (reading 'whenReady')" 收场。提前拦下并给出可执行的修复命令。
if (process.env.ELECTRON_RUN_AS_NODE) {
console.error("FAIL 检测到环境变量 ELECTRON_RUN_AS_NODE 已设置。");
console.error(" Electron 会因此退化成纯 Node,require('electron') 只返回二进制路径。");
console.error(" 清掉它再运行:");
console.error(" bash : unset ELECTRON_RUN_AS_NODE");
console.error(" cmd : set ELECTRON_RUN_AS_NODE=");
console.error(" PowerShell: Remove-Item Env:ELECTRON_RUN_AS_NODE");
process.exit(1);
}
const { app, BrowserWindow } = require("electron");
const path = require("path");
console.log("Electron:", process.versions.electron);
console.log("Chrome:", process.versions.chrome);
console.log("Node:", process.versions.node);
const distEntry = path.join(__dirname, "..", "dist", "index.html");
const rendererErrors = [];
const IGNORABLE = [
// 模型库是仓库外的外部资产,缺失时浏览器只会报资源 404,不影响应用本身
/模型库/,
/net::ERR_FILE_NOT_FOUND/,
];
// 加载 + 渲染 + 首帧动画都跑完,再判定结果
const SETTLE_MS = 4000;
app.whenReady().then(async () => {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
// 与 electron/main.cjs 保持一致,避免冒烟环境比生产环境宽松
nodeIntegration: false,
contextIsolation: true,
},
});
mainWindow.webContents.on("console-message", (_event, level, message) => {
// level: 0=verbose 1=info 2=warning 3=error
if (level >= 3 && !IGNORABLE.some((re) => re.test(message))) {
rendererErrors.push(message);
}
});
mainWindow.webContents.on("did-fail-load", (_event, code, description, url) => {
rendererErrors.push(`did-fail-load ${code} ${description} ${url}`);
});
mainWindow.webContents.on("render-process-gone", (_event, details) => {
rendererErrors.push(`render-process-gone ${details.reason}`);
});
try {
await mainWindow.loadFile(distEntry);
console.log("OK 页面加载成功:", distEntry);
} catch (error) {
console.error("FAIL 页面加载失败:", error.message);
console.error(" 请先执行 pnpm run build 生成 dist/");
app.exit(1);
return;
}
// 给 React 首帧和 R3F 初始化留出时间,暴露运行时错误
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
const rootChildCount = await mainWindow.webContents
.executeJavaScript("document.getElementById('root')?.childElementCount ?? -1")
.catch(() => -1);
if (rootChildCount > 0) {
console.log(`OK React 已挂载(#root 子节点 ${rootChildCount} 个)`);
} else {
rendererErrors.push(`#root 未渲染任何子节点(childElementCount=${rootChildCount})`);
}
if (rendererErrors.length === 0) {
console.log("PASS 冒烟测试通过:页面加载、React 挂载、无渲染层错误");
app.exit(0);
} else {
console.error(`FAIL 渲染层出现 ${rendererErrors.length} 个错误:`);
rendererErrors.slice(0, 10).forEach((message) => console.error(" -", message));
app.exit(1);
}
});
app.on("window-all-closed", () => {
app.quit();
});