mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Introduce the Electron desktop shell alongside the existing React renderer and local Bun server boundary. The migration keeps the DesktopHost contract explicit across Tauri, Electron, and browser runtimes while adding Electron main/preload services for dialogs, shell, notifications, updates, tray/window lifecycle, terminal, preview WebContentsView, app mode, and release/package validation.
The commit also carries the latest local main desktop command updates, including agent slash entries and hidden-by-default markdown thinking details, so the packaged Electron build matches the current main UX surface.
Constraint: React renderer, local Bun server, REST/WebSocket, and sidecar boundaries must remain reusable during the migration
Constraint: macOS dev packages are ad-hoc signed and cannot prove Developer ID notarization or Gatekeeper release launch
Rejected: Browser-only smoke validation | it cannot exercise native dialogs, keychain prompts, notification behavior, or packaged app startup
Confidence: medium
Scope-risk: broad
Directive: Do not remove Tauri host support until signed Electron release artifacts pass native OS smoke on macOS, Windows, and Linux
Tested: bun run check:desktop
Tested: cd desktop && bun run check:electron
Tested: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron📦dir
Tested: bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron
Tested: Computer Use read packaged Electron app window at desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app
Not-tested: Developer ID signed/notarized Gatekeeper launch
Not-tested: Real OS notification click-to-session action
Not-tested: Windows and Linux packaged app smoke on real hosts
564 lines
21 KiB
HTML
564 lines
21 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Electron 迁移交互式验收清单</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--bg: #f6f7f9;
|
||
--panel: #ffffff;
|
||
--text: #18202a;
|
||
--muted: #5f6b7a;
|
||
--line: #d8dee7;
|
||
--accent: #1565c0;
|
||
--danger: #b3261e;
|
||
--ok: #1b7f43;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #101418;
|
||
--panel: #171d24;
|
||
--text: #eef3f8;
|
||
--muted: #a7b1bf;
|
||
--line: #2b3542;
|
||
--accent: #7bb7ff;
|
||
}
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
header, main {
|
||
width: min(1120px, calc(100vw - 32px));
|
||
margin: 0 auto;
|
||
}
|
||
header {
|
||
padding: 28px 0 14px;
|
||
}
|
||
h1 {
|
||
margin: 0 0 8px;
|
||
font-size: 28px;
|
||
line-height: 1.2;
|
||
letter-spacing: 0;
|
||
}
|
||
.subtitle {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
}
|
||
.toolbar, .summary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin: 16px 0;
|
||
}
|
||
.stat, button, .tag {
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
}
|
||
.stat {
|
||
padding: 10px 12px;
|
||
min-width: 132px;
|
||
}
|
||
.stat strong {
|
||
display: block;
|
||
font-size: 20px;
|
||
}
|
||
button {
|
||
padding: 8px 11px;
|
||
cursor: pointer;
|
||
}
|
||
button:hover { border-color: var(--accent); }
|
||
.danger { color: var(--danger); }
|
||
.groups {
|
||
display: grid;
|
||
gap: 14px;
|
||
padding-bottom: 36px;
|
||
}
|
||
.card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
}
|
||
.card.done.hidden { display: none; }
|
||
.card-head {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
gap: 10px;
|
||
align-items: start;
|
||
}
|
||
.card h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
letter-spacing: 0;
|
||
}
|
||
.meta {
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
font-size: 13px;
|
||
}
|
||
.tag {
|
||
display: inline-flex;
|
||
padding: 2px 7px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.priority-p0 { color: var(--danger); }
|
||
.priority-p1 { color: var(--accent); }
|
||
ul {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 12px 0;
|
||
}
|
||
li {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
gap: 9px;
|
||
align-items: start;
|
||
padding: 7px 0;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
li:first-child { border-top: 0; }
|
||
input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
margin: 1px 0 0;
|
||
}
|
||
.pass {
|
||
margin: 10px 0;
|
||
padding: 10px 12px;
|
||
border-left: 3px solid var(--ok);
|
||
background: color-mix(in srgb, var(--ok) 8%, transparent);
|
||
}
|
||
textarea {
|
||
width: 100%;
|
||
min-height: 68px;
|
||
resize: vertical;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
background: transparent;
|
||
color: var(--text);
|
||
font: inherit;
|
||
}
|
||
.issues-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 14px;
|
||
}
|
||
.issue-row {
|
||
grid-template-columns: auto 1fr auto;
|
||
}
|
||
.issue-row button {
|
||
padding: 4px 7px;
|
||
font-size: 12px;
|
||
}
|
||
dialog {
|
||
width: min(520px, calc(100vw - 32px));
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
}
|
||
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
|
||
label span {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
color: var(--muted);
|
||
}
|
||
input[type="text"] {
|
||
width: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 9px;
|
||
background: transparent;
|
||
color: var(--text);
|
||
font: inherit;
|
||
}
|
||
.dialog-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 14px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Electron 迁移交互式验收清单</h1>
|
||
<p class="subtitle">覆盖 Tauri 2 到 Electron 迁移后的桌面能力、构建发布、实机 smoke 和当前 release gate blocker。进度只保存在本机浏览器 localStorage。</p>
|
||
<div class="summary" id="summary"></div>
|
||
<div class="toolbar">
|
||
<button id="toggle-completed" type="button">隐藏已完成</button>
|
||
<button id="export-md" type="button">导出 Markdown</button>
|
||
<button id="reset" type="button" class="danger">重置进度</button>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
<div class="groups" id="groups"></div>
|
||
</main>
|
||
|
||
<dialog id="issue-dialog">
|
||
<form method="dialog" id="issue-form">
|
||
<label>
|
||
<span>问题摘要</span>
|
||
<input id="issue-text" type="text" required>
|
||
</label>
|
||
<label style="margin-top: 10px; display: block;">
|
||
<span>详情 / 复现信息</span>
|
||
<textarea id="issue-details"></textarea>
|
||
</label>
|
||
<div class="dialog-actions">
|
||
<button value="cancel" type="button" id="cancel-issue">取消</button>
|
||
<button value="save" type="submit">保存</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<script>
|
||
const storageKey = "cc-haha-electron-migration-qa:2026-05-31";
|
||
const groups = [
|
||
{
|
||
id: "main-sync-regression",
|
||
priority: "P0",
|
||
title: "本地 main 同步回归",
|
||
refs: ["feat/electron-migration-main-sync-2392", "src/server/h5AccessPolicy.ts", "src/server/middleware/cors.ts"],
|
||
steps: [
|
||
"确认当前工作区已纳入本地 main 的最新变更。",
|
||
"确认 desktop/package.json、previewEvents、WorkspaceFileOpenWith 和 build-macos-arm64 冲突已按迁移意图解决。",
|
||
"运行 preview-agent 重建和 targeted desktop/server 回归。",
|
||
"确认 packaged Electron file:// renderer 在 H5 token mode 下可建立 chat WebSocket。"
|
||
],
|
||
pass: "迁移分支基于本地 main 最新代码继续迭代,file:// origin 不再阻断真实 packaged app chat。"
|
||
},
|
||
{
|
||
id: "mac-packaged-core",
|
||
priority: "P0",
|
||
title: "macOS packaged app 主链路",
|
||
refs: ["desktop/electron/main.ts", "desktop/electron/preload.ts", "Computer Use smoke"],
|
||
steps: [
|
||
"启动打包后的 macOS app,确认主界面从 packaged renderer 加载。",
|
||
"确认 local Bun server sidecar 自动启动,无需手动 SERVER_PORT。",
|
||
"打开终端,执行 printf electron-terminal-ok,并确认输出可见。",
|
||
"关闭终端后检查没有残留 PTY shell。",
|
||
"打开目录选择器并取消,确认 native folder dialog 可用。",
|
||
"复制本机 cc-haha provider/model 配置到隔离 CLAUDE_CONFIG_DIR,确认模型选择器显示真实 provider。",
|
||
"用真实模型读取 fixture 文件并审批执行 bun test,确认结果 1 pass / 0 fail,且项目文件未被修改。"
|
||
],
|
||
pass: "packaged app 可独立启动,server/terminal/dialog/真实模型 chat 四条核心桌面能力工作正常。"
|
||
},
|
||
{
|
||
id: "preview-workbench",
|
||
priority: "P0",
|
||
title: "Preview / Workbench Browser",
|
||
refs: ["desktop/electron/services/preview.ts", "desktop/electron/ipc/previewMessage.ts"],
|
||
steps: [
|
||
"打开 preview/workbench browser panel。",
|
||
"导航到 https://example.com/,确认 WebContentsView 渲染页面。",
|
||
"点击截图,确认 screenshot-full.png 回填到 composer。",
|
||
"在外部页面触发 selection screenshot 或 message bridge 场景,确认无越权事件。"
|
||
],
|
||
pass: "preview 能加载外部页面、发送受控事件、截图回填,且 bridge 只接受 allowlist 事件和合法 payload。"
|
||
},
|
||
{
|
||
id: "system-integration",
|
||
priority: "P1",
|
||
title: "系统交互能力",
|
||
refs: ["desktop/src/lib/desktopHost", "desktop/electron/services/dialogs.ts", "desktop/electron/services/shell.ts"],
|
||
steps: [
|
||
"选择附件文件,确认文件加入 composer。",
|
||
"打开 Python 路径选择,确认系统文件选择器可用。",
|
||
"点击外链或 OAuth 帮助链接,确认走系统浏览器。",
|
||
"关闭主窗口,确认 Dock/tray 可恢复窗口。",
|
||
"触发通知,点击通知后回到目标 session。",
|
||
"如 Computer Use 对 Finder/list_apps 仍返回 NSOSStatusErrorDomain Code=-600 procNotFound,记录为自动化桥阻塞,不勾选本组。"
|
||
],
|
||
pass: "文件、外链、窗口恢复和通知均走 Electron host adapter,不再依赖 Tauri runtime。"
|
||
},
|
||
{
|
||
id: "updates",
|
||
priority: "P1",
|
||
title: "自动更新状态流",
|
||
refs: ["desktop/electron/services/updater.ts", "desktop/src/stores/updateStore.ts"],
|
||
steps: [
|
||
"触发 update check 或 mock updater。",
|
||
"确认 unpacked dir package 缺少 app-update.yml 时视为无更新且不阻塞 UI。",
|
||
"确认 check/download/install/relaunch 状态不阻塞 UI。",
|
||
"模拟失败后重试,确认无需重新下载已完成的更新。",
|
||
"确认 install/relaunch 前会停止 server/adapter sidecar。"
|
||
],
|
||
pass: "更新通知、后台下载、安装重试和 sidecar stop 流程保持迁移前语义。"
|
||
},
|
||
{
|
||
id: "build-release",
|
||
priority: "P0",
|
||
title: "构建发布与 package smoke",
|
||
refs: [".github/workflows/release-desktop.yml", "scripts/quality-gate/package-smoke/index.ts"],
|
||
steps: [
|
||
"运行 cd desktop && CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir。",
|
||
"运行 bun run test:package-smoke --platform macos。",
|
||
"release-ready 前运行 bun run test:package-smoke --platform macos --require-macos-gatekeeper,确认 macOS Gatekeeper launch approval 通过。",
|
||
"检查 release workflow 在 electron-builder 后运行 package-smoke。",
|
||
"确认 macOS app.asar、unpacked sidecar、node-pty native module 和 spawn-helper 都存在。",
|
||
"若存在 latest-mac.yml / latest.yml / latest-linux.yml,确认其中 url/path 引用的 artifact 文件真实存在。",
|
||
"确认 Electron Builder publish 配置不依赖 git remote 自动探测。"
|
||
],
|
||
pass: "关键 runtime 资源或 update metadata asset 引用缺失时 CI/release 会失败,macOS 本地产物可静态验包。"
|
||
},
|
||
{
|
||
id: "release-live-gate",
|
||
priority: "P0",
|
||
title: "Release live gate 收口",
|
||
refs: ["scripts/quality-gate/modes.ts", "artifacts/quality-runs/2026-05-31T20-15-04-797Z/report.md"],
|
||
steps: [
|
||
"确认 release mode 不再把 agent-browser/Vite smoke 当作 Electron packaged app 必需验收。",
|
||
"确认 desktop-package-smoke:<platform> lane 存在并实际运行通过。",
|
||
"重跑 bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>。",
|
||
"确认 provider-smoke 和 baseline cases 通过,或记录新的 provider/live blocker。",
|
||
"确认 Computer Use 真实 packaged app 验收仍作为 GUI 行为证据保存。"
|
||
],
|
||
pass: "release live gate 全绿;最新证据 artifacts/quality-runs/2026-05-31T20-29-45-758Z/report.md 为 passed=15 failed=0 skipped=0,浏览器 open 超时不再替代 Electron app 验收。"
|
||
},
|
||
{
|
||
id: "windows-real-machine",
|
||
priority: "P0",
|
||
title: "Windows 实机验收",
|
||
refs: ["desktop/scripts/build-windows-x64.ps1", "electron-builder NSIS"],
|
||
steps: [
|
||
"构建 NSIS 安装器并安装。",
|
||
"运行 bun run test:package-smoke --platform windows。",
|
||
"启动安装后的 app,确认 sidecar 自动启动。",
|
||
"验证通知、tray、窗口隐藏/恢复、文件选择器和外链。",
|
||
"验证更新前 stop process 与安装重启。"
|
||
],
|
||
pass: "Windows 安装产物具备迁移前桌面能力,无 Electron packaging 资源缺失。"
|
||
},
|
||
{
|
||
id: "linux-real-machine",
|
||
priority: "P0",
|
||
title: "Linux 实机验收",
|
||
refs: ["electron-builder AppImage/deb"],
|
||
steps: [
|
||
"构建 AppImage/deb。",
|
||
"运行 bun run test:package-smoke --platform linux。",
|
||
"启动 AppImage/deb 安装后的 app,确认 sidecar 自动启动。",
|
||
"验证 tray、通知、系统对话框、外链和更新策略。",
|
||
"记录平台能力降级并同步 release note。"
|
||
],
|
||
pass: "Linux 产物可启动,关键系统集成可用;不可用能力有明确 release note。"
|
||
}
|
||
];
|
||
|
||
const defaultState = { checked: {}, notes: {}, issueTodos: {} };
|
||
let state = loadState();
|
||
let hideCompleted = false;
|
||
let activeIssue = null;
|
||
|
||
function loadState() {
|
||
try {
|
||
return { ...defaultState, ...JSON.parse(localStorage.getItem(storageKey) || "{}") };
|
||
} catch {
|
||
return { ...defaultState };
|
||
}
|
||
}
|
||
|
||
function saveState() {
|
||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||
render();
|
||
}
|
||
|
||
function stepKey(group, index) {
|
||
return `${group.id}:${index}`;
|
||
}
|
||
|
||
function groupDone(group) {
|
||
return group.steps.every((_, index) => state.checked[stepKey(group, index)]);
|
||
}
|
||
|
||
function renderSummary() {
|
||
const totalSteps = groups.reduce((sum, group) => sum + group.steps.length, 0);
|
||
const checkedSteps = groups.reduce((sum, group) => sum + group.steps.filter((_, index) => state.checked[stepKey(group, index)]).length, 0);
|
||
const doneGroups = groups.filter(groupDone).length;
|
||
const openIssues = Object.values(state.issueTodos).flat().filter(issue => !issue.done).length;
|
||
document.getElementById("summary").innerHTML = [
|
||
stat("步骤完成", `${checkedSteps}/${totalSteps}`),
|
||
stat("组完成", `${doneGroups}/${groups.length}`),
|
||
stat("未解决问题", String(openIssues)),
|
||
stat("存储键", storageKey)
|
||
].join("");
|
||
}
|
||
|
||
function stat(label, value) {
|
||
return `<div class="stat"><strong>${escapeHtml(value)}</strong>${escapeHtml(label)}</div>`;
|
||
}
|
||
|
||
function renderGroups() {
|
||
const root = document.getElementById("groups");
|
||
root.innerHTML = "";
|
||
for (const group of groups) {
|
||
const done = groupDone(group);
|
||
const checkedCount = group.steps.filter((_, index) => state.checked[stepKey(group, index)]).length;
|
||
const card = document.createElement("section");
|
||
card.className = `card ${done ? "done" : ""} ${hideCompleted && done ? "hidden" : ""}`;
|
||
card.innerHTML = `
|
||
<div class="card-head">
|
||
<input type="checkbox" data-group="${group.id}" ${done ? "checked" : ""} aria-label="完成 ${escapeHtml(group.title)}">
|
||
<div>
|
||
<h2>${escapeHtml(group.title)}</h2>
|
||
<div class="meta">${group.refs.map(escapeHtml).join(" · ")}</div>
|
||
</div>
|
||
<span class="tag priority-${group.priority.toLowerCase()}">${group.priority} · ${checkedCount}/${group.steps.length} 步</span>
|
||
</div>
|
||
<ul>
|
||
${group.steps.map((step, index) => `
|
||
<li>
|
||
<input type="checkbox" data-step="${group.id}:${index}" ${state.checked[stepKey(group, index)] ? "checked" : ""}>
|
||
<span>${escapeHtml(step)}</span>
|
||
</li>
|
||
`).join("")}
|
||
</ul>
|
||
<div class="pass"><strong>通过标准:</strong>${escapeHtml(group.pass)}</div>
|
||
<div class="issues-head">
|
||
<strong>问题记录</strong>
|
||
<button type="button" data-add-issue="${group.id}">+ 添加问题</button>
|
||
</div>
|
||
<ul>${renderIssues(group)}</ul>
|
||
<textarea data-note="${group.id}" placeholder="记录观察、环境、复现线索;不要保存密钥或 token。">${escapeHtml(state.notes[group.id] || "")}</textarea>
|
||
`;
|
||
root.appendChild(card);
|
||
}
|
||
}
|
||
|
||
function renderIssues(group) {
|
||
const issues = state.issueTodos[group.id] || [];
|
||
if (issues.length === 0) {
|
||
return "<li><span></span><span class=\"meta\">暂无问题</span></li>";
|
||
}
|
||
return issues.map(issue => `
|
||
<li class="issue-row">
|
||
<input type="checkbox" data-issue="${group.id}:${issue.id}" ${issue.done ? "checked" : ""}>
|
||
<span>${escapeHtml(issue.text)}${issue.details ? `<br><span class="meta">${escapeHtml(issue.details)}</span>` : ""}</span>
|
||
<button type="button" data-edit-issue="${group.id}:${issue.id}">编辑</button>
|
||
</li>
|
||
`).join("");
|
||
}
|
||
|
||
function render() {
|
||
renderSummary();
|
||
renderGroups();
|
||
bindEvents();
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.querySelectorAll("[data-group]").forEach(input => {
|
||
input.onchange = () => {
|
||
const group = groups.find(item => item.id === input.dataset.group);
|
||
group.steps.forEach((_, index) => { state.checked[stepKey(group, index)] = input.checked; });
|
||
saveState();
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-step]").forEach(input => {
|
||
input.onchange = () => {
|
||
state.checked[input.dataset.step] = input.checked;
|
||
saveState();
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-note]").forEach(textarea => {
|
||
textarea.oninput = () => {
|
||
state.notes[textarea.dataset.note] = textarea.value;
|
||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-add-issue]").forEach(button => {
|
||
button.onclick = () => openIssueDialog(button.dataset.addIssue);
|
||
});
|
||
document.querySelectorAll("[data-edit-issue]").forEach(button => {
|
||
button.onclick = () => {
|
||
const [groupId, issueId] = button.dataset.editIssue.split(":");
|
||
openIssueDialog(groupId, issueId);
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-issue]").forEach(input => {
|
||
input.onchange = () => {
|
||
const [groupId, issueId] = input.dataset.issue.split(":");
|
||
const issue = (state.issueTodos[groupId] || []).find(item => item.id === issueId);
|
||
if (issue) issue.done = input.checked;
|
||
saveState();
|
||
};
|
||
});
|
||
}
|
||
|
||
function openIssueDialog(groupId, issueId) {
|
||
const dialog = document.getElementById("issue-dialog");
|
||
const issue = issueId ? (state.issueTodos[groupId] || []).find(item => item.id === issueId) : null;
|
||
activeIssue = { groupId, issueId };
|
||
document.getElementById("issue-text").value = issue?.text || "";
|
||
document.getElementById("issue-details").value = issue?.details || "";
|
||
dialog.showModal();
|
||
}
|
||
|
||
document.getElementById("issue-form").onsubmit = event => {
|
||
event.preventDefault();
|
||
const text = document.getElementById("issue-text").value.trim();
|
||
const details = document.getElementById("issue-details").value.trim();
|
||
if (!text || !activeIssue) return;
|
||
const list = state.issueTodos[activeIssue.groupId] || [];
|
||
const existing = activeIssue.issueId ? list.find(item => item.id === activeIssue.issueId) : null;
|
||
if (existing) {
|
||
existing.text = text;
|
||
existing.details = details;
|
||
} else {
|
||
list.push({ id: `issue-${Date.now()}`, text, details, done: false });
|
||
}
|
||
state.issueTodos[activeIssue.groupId] = list;
|
||
document.getElementById("issue-dialog").close();
|
||
saveState();
|
||
};
|
||
|
||
document.getElementById("cancel-issue").onclick = () => document.getElementById("issue-dialog").close();
|
||
document.getElementById("toggle-completed").onclick = () => {
|
||
hideCompleted = !hideCompleted;
|
||
document.getElementById("toggle-completed").textContent = hideCompleted ? "显示已完成" : "隐藏已完成";
|
||
render();
|
||
};
|
||
document.getElementById("reset").onclick = () => {
|
||
if (confirm("确认重置全部本地进度?")) {
|
||
state = { ...defaultState };
|
||
localStorage.removeItem(storageKey);
|
||
render();
|
||
}
|
||
};
|
||
document.getElementById("export-md").onclick = () => {
|
||
const markdown = groups.map(group => {
|
||
const steps = group.steps.map((step, index) => `- [${state.checked[stepKey(group, index)] ? "x" : " "}] ${step}`).join("\n");
|
||
const issues = (state.issueTodos[group.id] || []).map(issue => `- [${issue.done ? "x" : " "}] ${issue.text}${issue.details ? ` - ${issue.details}` : ""}`).join("\n") || "- 无";
|
||
const notes = state.notes[group.id] ? `\n备注:${state.notes[group.id]}` : "";
|
||
return `## ${group.priority} ${group.title}\n\n${steps}\n\n通过标准:${group.pass}\n\n问题记录:\n${issues}${notes}`;
|
||
}).join("\n\n");
|
||
navigator.clipboard.writeText(markdown).then(() => alert("已复制 Markdown 到剪贴板"));
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value).replace(/[&<>"']/g, char => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
"\"": """,
|
||
"'": "'"
|
||
}[char]));
|
||
}
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|