Skip to content

Commit 1a7762b

Browse files
committed
refactor(ui): replace window confirm with dialog
Migrate all 8 window.confirm call sites (6 files) to the ConfirmDialog primitive. Original copy is preserved verbatim, split into title/message/impacts at its own delimiters; invoke arguments, busy wiring and the cancel path (the branch where window.confirm returned false) are equivalent - including NotePreviewView's cancel side effect (setStatus). Durable doc corrections in the same commit: - ADR-033 consequence 4: the tier rationale was wrong. Measured nested ConfirmDialog consumers = 0, so the >=3 threshold does not hold; the real basis is the B6 special clause (inexpressible at the call site). Code unchanged. - ADR-033:41 line-count table: style-seams.test.ts 271 -> 287. line-limit-exemptions.md: sync only the 3 existing rows (390/416/340), zero new registrations; --write deliberately not run (it would serialize other units' in-flight numbers into this commit). New src/components/confirmMigration.test.tsx (4 source-level guards plus per-site render equivalence); FeedFragmentList.test.tsx confirm stub replaced by button clicks (authorized rewrite, 4 -> 5 cases).
1 parent 5d485ff commit 1a7762b

9 files changed

Lines changed: 572 additions & 37 deletions

‎app/src/components/AiProviderSettings.tsx‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { useEffect, useState } from "react";
1414
import { invoke } from "@tauri-apps/api/core";
1515
import type { AiProviderInput, AiProviderView } from "../types";
16+
import { ConfirmDialog } from "../ui/primitives";
1617

1718
const btn: React.CSSProperties = {
1819
padding: "5px 10px",
@@ -51,6 +52,8 @@ export default function AiProviderSettings() {
5152
const [keyInput, setKeyInput] = useState("");
5253
// I-3:预设双源——后端 ai_provider_presets 优先,失败回退内置常量
5354
const [presetOptions, setPresetOptions] = useState(PRESET_OPTIONS);
55+
/** 批 4 T11:待确认的危险动作(删 Provider / 清密钥)——`window.confirm` 的同步返回值 → 受控弹层 */
56+
const [pending, setPending] = useState<{ kind: "remove" | "clearKey"; id: string } | null>(null);
5457

5558
useEffect(() => {
5659
void load();
@@ -91,8 +94,7 @@ export default function AiProviderSettings() {
9194
});
9295

9396
const removeProvider = (id: string) => {
94-
if (!window.confirm("删除后该 Provider 配置与密钥将永久清除,且不可恢复。确定删除?")) return;
95-
run(() => invoke("ai_provider_remove", { id }), "已删除");
97+
setPending({ kind: "remove", id });
9698
};
9799

98100
const setDefault = (id: string) =>
@@ -117,8 +119,27 @@ export default function AiProviderSettings() {
117119
);
118120

119121
const clearKey = (id: string) => {
120-
if (!window.confirm("清除后密钥不可恢复(需重新输入)。确定清除?")) return;
121-
run(() => invoke("ai_provider_clear_key", { id }), "密钥已清除");
122+
setPending({ kind: "clearKey", id });
123+
};
124+
125+
/**
126+
* 确认后执行(批 4 T11:两处 `window.confirm` → 一个受控 `ConfirmDialog`)。
127+
*
128+
* Why 两处合成一个 `pending`:两个动作**确认后的分支**只差 invoke 命令与成功文案,
129+
* 而 `window.confirm` 的同步返回值无法在受控弹层里表达 ⇒ 待确认的「动作种类 + 目标 id」
130+
* 必须进 state;`kind` 判别式让两条路径共用一个弹层、一份 `busy` 门。
131+
* Why 不再用 `window.confirm`:规格 §5.1 —— WebView2 下可能**静默返回 false**(点删除没反应),
132+
* 且 §5.3 要求高危不可逆动作在框内列明级联影响与保留项。
133+
* Why 先 `setPending(null)` 再执行:与原文「confirm 返回 true 后立刻往下走」同序 ——
134+
* 弹层进入退场相位(那 160ms 内按钮由 `ConfirmDialog` 置为失活),动作只可能被触发一次。
135+
* 文案逐字拆解见渲染处:question 句 → title,说明句 → message,其余 → impacts。
136+
*/
137+
const runPending = () => {
138+
const p = pending;
139+
setPending(null);
140+
if (p === null) return;
141+
if (p.kind === "remove") run(() => invoke("ai_provider_remove", { id: p.id }), "已删除");
142+
else run(() => invoke("ai_provider_clear_key", { id: p.id }), "密钥已清除");
122143
};
123144

124145
return (
@@ -153,12 +174,12 @@ export default function AiProviderSettings() {
153174
{!p.isDefault && <button style={btn} onClick={() => void setDefault(p.id)} disabled={busy}>设为默认</button>}
154175
<button style={btn} onClick={() => void toggleEnabled(p)} disabled={busy}>{p.enabled ? "禁用" : "启用"}</button>
155176
{p.hasKey ? (
156-
<button style={btn} onClick={() => void clearKey(p.id)} disabled={busy}>清除密钥</button>
177+
<button style={btn} onClick={() => clearKey(p.id)} disabled={busy}>清除密钥</button>
157178
) : (
158179
<button style={btn} onClick={() => { setKeyInputId(p.id); setKeyInput(""); }} disabled={busy}>配置密钥</button>
159180
)}
160181
{!p.isDefault && (
161-
<button style={{ ...btn, color: "#dc2626" }} onClick={() => void removeProvider(p.id)} disabled={busy}>删除</button>
182+
<button style={{ ...btn, color: "#dc2626" }} onClick={() => removeProvider(p.id)} disabled={busy}>删除</button>
162183
)}
163184
</div>
164185
{keyInputId === p.id && (
@@ -229,6 +250,23 @@ export default function AiProviderSettings() {
229250
{msg.text}
230251
</div>
231252
)}
253+
254+
{/* 危险动作确认(§5.3):原文两处 `window.confirm` 的文案逐字拆为 title(问句)/ message / impacts */}
255+
<ConfirmDialog
256+
open={pending !== null}
257+
title={pending?.kind === "clearKey" ? "确定清除?" : "确定删除?"}
258+
message={pending?.kind === "clearKey"
259+
? "清除后密钥不可恢复(需重新输入)。"
260+
: "删除后该 Provider 配置与密钥将永久清除,且不可恢复。"}
261+
impacts={pending?.kind === "clearKey"
262+
? [{ text: "端点与模型列表保留", keep: true }]
263+
: [{ text: "Provider 配置与密钥将永久清除" }, { text: "其它 Provider 不受影响", keep: true }]}
264+
confirmLabel={pending?.kind === "clearKey" ? "清除" : "删除"}
265+
busy={busy}
266+
onConfirm={runPending}
267+
onCancel={() => setPending(null)}
268+
testId="ai-provider-danger-confirm"
269+
/>
232270
</div>
233271
);
234272
}

‎app/src/components/BackupPanel.tsx‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { useState } from "react";
1212
import { invoke } from "@tauri-apps/api/core";
1313
import { open } from "@tauri-apps/plugin-dialog";
14+
import { ConfirmDialog } from "../ui/primitives";
1415

1516
interface BackupSummary {
1617
archivePath: string;
@@ -30,6 +31,8 @@ export default function BackupPanel() {
3031
const [busy, setBusy] = useState(false);
3132
const [error, setError] = useState("");
3233
const [info, setInfo] = useState("");
34+
/** 待确认恢复的备份路径(批 4 T11:`window.confirm` → `ConfirmDialog`,见 `pickRestore` 的 Why) */
35+
const [pendingRestore, setPendingRestore] = useState<string | null>(null);
3336

3437
const createBackup = async () => {
3538
setBusy(true);
@@ -45,19 +48,30 @@ export default function BackupPanel() {
4548
}
4649
};
4750

48-
const restore = async () => {
51+
/**
52+
* 选文件 → 置待确认态(批 4 T11)。
53+
*
54+
* Why 不再是 `window.confirm`:规格 §5.1 —— `window.confirm` 在 WebView2 下**可能静默返回 false**,
55+
* 而这里是「覆盖当前全部数据」的高危不可逆动作(§5.3),静默 false 会表现为"点了没反应"。
56+
* Why 拆两段(选文件 / 真恢复):`window.confirm` 是同步的一行,`ConfirmDialog` 是受控弹层 ——
57+
* 选中的路径必须留在 state 里活到用户点确认;`runRestore` 里的 `busy` 置位/清理与 `invoke` 实参
58+
* 与迁移前**逐字相同**,只是触发点从 confirm 的返回值变成确认按钮。
59+
*/
60+
const pickRestore = async () => {
4961
// 文件选择(zip)
5062
const selected = await open({ multiple: false, filters: [{ name: "备份文件", extensions: ["zip"] }] });
5163
if (typeof selected !== "string" || !selected) return;
5264
// 显式确认(恢复覆盖当前数据——TRUST-1 高影响操作)
53-
if (!window.confirm("恢复将覆盖当前全部数据(现有数据库改名 .pre-restore 兜底)。\n继续?")) {
54-
return;
55-
}
65+
setPendingRestore(selected);
66+
};
67+
68+
/** 真正执行恢复(确认按钮触发;`archivePath` 即原来那个 `selected`) */
69+
const runRestore = async (archivePath: string) => {
5670
setBusy(true);
5771
setError("");
5872
setInfo("");
5973
try {
60-
const n = await invoke<number>("backup_restore", { archivePath: selected });
74+
const n = await invoke<number>("backup_restore", { archivePath });
6175
setInfo(`恢复完成(${n} 个文件)。请重启应用使数据生效。`);
6276
} catch (e) {
6377
setError(`恢复失败: ${e}`);
@@ -79,7 +93,7 @@ export default function BackupPanel() {
7993
{busy ? "处理中…" : "创建备份"}
8094
</button>
8195
<button
82-
onClick={() => void restore()}
96+
onClick={() => void pickRestore()}
8397
disabled={busy}
8498
style={{ padding: "3px 10px", fontSize: 11, border: "1px solid #fca5a5", borderRadius: 6, background: "#fff", color: "#dc2626", cursor: busy ? "wait" : "pointer" }}
8599
title="从备份 zip 恢复(覆盖当前数据,需重启生效)"
@@ -92,6 +106,25 @@ export default function BackupPanel() {
92106
<div style={{ fontSize: 10, color: "#9ca3af" }}>
93107
备份保存在应用数据目录 backups/(本地优先:数据不出本机);如需异地保存请自行拷贝备份文件
94108
</div>
109+
110+
{/* 高危确认(§5.3):原文案「恢复将覆盖当前全部数据(现有数据库改名 .pre-restore 兜底)。\n继续?」
111+
逐字拆为 title(首句)+ impacts(括号内的保留项)+ 确认按钮「继续」 */}
112+
<ConfirmDialog
113+
open={pendingRestore !== null}
114+
title="恢复将覆盖当前全部数据"
115+
impacts={[
116+
{ text: "现有数据库改名 .pre-restore 兜底", keep: true },
117+
]}
118+
confirmLabel="继续"
119+
busy={busy}
120+
onConfirm={() => {
121+
const path = pendingRestore;
122+
setPendingRestore(null);
123+
if (path !== null) void runRestore(path);
124+
}}
125+
onCancel={() => setPendingRestore(null)}
126+
testId="backup-restore-confirm"
127+
/>
95128
</div>
96129
);
97130
}

‎app/src/components/FeedFragmentList.test.tsx‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,24 +140,64 @@ describe("FeedFragmentList 收件箱状态机", () => {
140140
});
141141

142142
it("删除:二次确认后移除(取消则保留)", async () => {
143-
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
143+
// 批 4 T11:确认从 `window.confirm` 迁到 `ConfirmDialog` ⇒ 打桩改成**点按钮**;
144+
// 桩仍在位、但断言它**一次都没被调用**(比原来的 mockReturnValue 更强:旧写法证不了"没走命令式确认")。
145+
const confirmSpy = vi.spyOn(window, "confirm");
144146
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
145147
await screen.findByTestId("fragment-card-2");
146148

147149
const card2 = screen.getByTestId("fragment-card-2");
148150
fireEvent.click(within(card2).getByText("🗑 删除"));
151+
// 二次确认:原文案三行逐字进弹层(标题 / 碎片预览 / 保留项)
152+
const dialog = await screen.findByTestId("fragment-delete-confirm");
153+
expect(dialog.textContent).toContain("删除这条碎片?");
154+
expect(dialog.textContent).toContain("「单句灵感…」");
155+
expect(dialog.textContent).toContain("绑定的闪卡会保留");
156+
fireEvent.click(screen.getByTestId("fragment-delete-confirm-confirm"));
149157
await waitFor(() => {
150158
expect(invokeMock).toHaveBeenCalledWith("delete_fragment", { fragmentId: 2 });
151159
});
152160
await waitFor(() => expect(screen.queryByTestId("fragment-card-2")).toBeNull());
153161

154-
// 取消:确认框拒绝 → 不触发删除
155-
confirmSpy.mockReturnValue(false);
162+
// 取消:确认框取消 → 不触发删除(碎片仍在)
156163
invokeMock.mockClear();
157164
const card1 = screen.getByTestId("fragment-card-1");
158165
fireEvent.click(within(card1).getByText("🗑 删除"));
166+
fireEvent.click(await screen.findByTestId("fragment-delete-confirm-cancel"));
159167
await new Promise((r) => setTimeout(r, 0));
160168
expect(invokeMock).not.toHaveBeenCalledWith("delete_fragment", { fragmentId: 1 });
169+
expect(screen.getByTestId("fragment-card-1")).toBeTruthy();
170+
expect(confirmSpy).not.toHaveBeenCalled();
161171
confirmSpy.mockRestore();
162172
});
173+
174+
it("删除进行中:重复点击只执行一次(busy 门 —— 批 4 T11 的 M3 判据)", async () => {
175+
// Arrange:让 delete_fragment 挂起(动作「在飞」)
176+
let release: (v: unknown) => void = () => undefined;
177+
invokeMock.mockImplementation(async (cmd: string) => {
178+
if (cmd === "list_fragments") return dbFragments;
179+
if (cmd === "resolve_fragment_image") return null;
180+
if (cmd === "delete_fragment") return new Promise((r) => { release = r; });
181+
throw new Error(`unexpected command: ${cmd}`);
182+
});
183+
const count = (): number => invokeMock.mock.calls.filter((c) => c[0] === "delete_fragment").length;
184+
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
185+
await screen.findByTestId("fragment-card-2");
186+
const trigger = (): HTMLButtonElement =>
187+
within(screen.getByTestId("fragment-card-2")).getByText("🗑 删除").closest("button") as HTMLButtonElement;
188+
189+
// Act:确认一次(invoke 在飞)→ 再点触发钮 + 再点确认钮
190+
fireEvent.click(trigger());
191+
fireEvent.click(await screen.findByTestId("fragment-delete-confirm-confirm"));
192+
await waitFor(() => expect(count()).toBe(1));
193+
expect(trigger().hasAttribute("disabled"), "动作在飞时触发钮必须 disabled").toBe(true);
194+
fireEvent.click(trigger());
195+
fireEvent.click(screen.getByTestId("fragment-delete-confirm-confirm"));
196+
197+
// Assert:**只执行一次**(若 busy 未接:触发钮可再点 ⇒ 新弹层 open=true 且按钮不 busy ⇒ 第二次 invoke)
198+
expect(count()).toBe(1);
199+
release({ deleted: true, autoCleanedGroups: [] });
200+
await waitFor(() => expect(trigger().hasAttribute("disabled")).toBe(false));
201+
expect(count()).toBe(1);
202+
});
163203
});

‎app/src/components/FeedFragmentList.tsx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Fragment, Note, NoteGroup } from "../types";
1616
// REQ-316(批 7):碎片删除/升笔记返回契约(源空组清理留痕数据源)
1717
import type { DeleteFragmentResult, PromoteNoteResult } from "../types/notes";
1818
import { fragmentPreview, promoteTitleFor } from "../utils/inbox";
19+
import { ConfirmDialog } from "../ui/primitives";
1920

2021
interface Props {
2122
/** 列宽(v0.15 全站自适应——父层 useColumnLayout 驱动;缺省 320=历史值) */
@@ -80,6 +81,8 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
8081
// 归组下拉选项(升笔记时惰性加载容器组)
8182
const [containerGroups, setContainerGroups] = useState<NoteGroup[] | null>(null);
8283
const [busy, setBusy] = useState(false);
84+
/** 待确认删除的碎片(批 4 T11):`window.confirm` 的同步返回值 → 受控弹层 + 这份待确认态 */
85+
const [pendingDelete, setPendingDelete] = useState<Fragment | null>(null);
8386

8487
const load = useCallback(async () => {
8588
try {
@@ -158,9 +161,14 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
158161
}
159162
};
160163

161-
// 🗑 删除(二次确认;绑定卡保留)
164+
/**
165+
* 🗑 删除(二次确认;绑定卡保留)。
166+
*
167+
* Why 确认移到 `ConfirmDialog`(批 4 T11):规格 §5.1 —— `window.confirm` 在 WebView2 下可能**静默
168+
* 返回 false**(真机表现为"点了删除没反应",且不可观测);原一行命令式确认的字面量逐字拆进弹层
169+
* (title 首行 + message 碎片预览 + impacts 保留项),`invoke` 实参与 `busy` 时序与迁移前相同。
170+
*/
162171
const runDelete = async (f: Fragment) => {
163-
if (!window.confirm(`删除这条碎片?\n「${f.text.slice(0, 30)}…」\n(绑定的闪卡会保留)`)) return;
164172
setBusy(true);
165173
try {
166174
const r = await invoke<DeleteFragmentResult>("delete_fragment", { fragmentId: f.id });
@@ -217,7 +225,7 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
217225
⚙ 升为闪卡
218226
</button>
219227
<button
220-
onClick={() => void runDelete(f)}
228+
onClick={() => setPendingDelete(f)}
221229
disabled={busy}
222230
style={{ fontSize: 10, cursor: "pointer", padding: "1px 8px", borderRadius: 4, border: "1px solid #fecaca", background: "#fff", color: "#dc2626" }}
223231
title="删除碎片(绑定卡保留)"
@@ -272,6 +280,18 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
272280

273281
{err && <p data-testid="inbox-error" style={{ fontSize: 11, color: "#dc2626", marginTop: 4 }}>{err}</p>}
274282
</div>
283+
284+
<ConfirmDialog
285+
open={pendingDelete !== null}
286+
title="删除这条碎片?"
287+
message={pendingDelete ? `「${pendingDelete.text.slice(0, 30)}…」` : undefined}
288+
impacts={[{ text: "绑定的闪卡会保留", keep: true }]}
289+
confirmLabel="删除"
290+
busy={busy}
291+
onConfirm={() => { const f = pendingDelete; setPendingDelete(null); if (f) void runDelete(f); }}
292+
onCancel={() => setPendingDelete(null)}
293+
testId="fragment-delete-confirm"
294+
/>
275295
</div>
276296
);
277297
}

0 commit comments

Comments
 (0)