From 3cf430d8231617aa79f40e7774dedb27ed8fc622 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Fri, 6 Nov 2026 03:34:59 -0800 Subject: [PATCH] feat(studio): restoring a backup asks for the passphrase and reads the differences in a dialog, and says what happened in one strip per outcome, as the recovery board draws it --- desktop/frontend-next/src/design/recovery.css | 22 ++++ desktop/frontend-next/src/i18n/msg/data.ts | 1 + .../BackupRestore.dialog.interaction.test.tsx | 69 +++++++++++ .../frontend-next/src/ui/BackupRestore.tsx | 109 ++++++++++-------- 4 files changed, 153 insertions(+), 48 deletions(-) create mode 100644 desktop/frontend-next/src/ui/BackupRestore.dialog.interaction.test.tsx diff --git a/desktop/frontend-next/src/design/recovery.css b/desktop/frontend-next/src/design/recovery.css index ceeae4a0d0..4d216ef577 100644 --- a/desktop/frontend-next/src/design/recovery.css +++ b/desktop/frontend-next/src/design/recovery.css @@ -72,3 +72,25 @@ .toolfb { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); color: var(--text); } .toolfb .toolfb-nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .toolfb .srcfb-tag { display: inline-grid; place-items: center; height: 20px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; } +.ns-dlg.bk-dlg[data-wide] { width: 560px; } +.ns-dlg.bk-dlg .bk-dlg-body { padding: 10px 16px 14px; max-height: min(60vh, 520px); overflow-y: auto; } +.ns-dlg.bk-dlg .bk-dlg-body .acct-note { margin: 0 0 8px; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.7 var(--ui); } +.ns-dlg.bk-dlg .bk-fields[data-one] { grid-template-columns: 1fr; } +.ns-dlg.bk-dlg > footer { align-items: center; gap: 8px; } +.ns-dlg.bk-dlg > footer .acct-note { flex: 1; margin: 0; color: var(--warn-ink); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); } +.ns-dlg.bk-dlg > footer .btn { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1 var(--ui); cursor: pointer; } +.ns-dlg.bk-dlg > footer .btn[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); } +.ns-dlg.bk-dlg > footer .btn:disabled { opacity: .45; cursor: default; } +.ns-dlg.bk-dlg > footer .btn .studio-icon { width: 14px; height: 14px; } +.bk-result { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; } +.bk-strip { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); } +.bk-strip > span { flex: 1; min-width: 0; } +.bk-strip[data-tone="ok"] { background: color-mix(in srgb, var(--ok) 10%, transparent); } +.bk-strip[data-tone="ok"] > .studio-icon { color: var(--ok-ink); } +.bk-strip[data-tone="warn"] { background: color-mix(in srgb, var(--warn) 12%, transparent); } +.bk-strip[data-tone="warn"] > .studio-icon { color: var(--warn-ink); } +.bk-strip[data-tone="plain"] { flex-wrap: wrap; background: var(--overlay); color: var(--muted); } +.bk-strip .studio-icon { flex: none; width: 16px; height: 16px; } +.bk-strip .bk-plug { display: inline-flex; align-items: center; gap: 8px; } +.bk-strip .bk-plug .btn { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; } +.bk-strip .ns-cancel { flex: none; height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; } diff --git a/desktop/frontend-next/src/i18n/msg/data.ts b/desktop/frontend-next/src/i18n/msg/data.ts index 01c3ad6218..362de386b3 100644 --- a/desktop/frontend-next/src/i18n/msg/data.ts +++ b/desktop/frontend-next/src/i18n/msg/data.ts @@ -113,6 +113,7 @@ export const DATA: Messages = { "data.repeat_it": { zh: "再输一次", en: "Repeat it" }, "data.repeat_the_passphrase_to_confirm": { zh: "请再输一次口令", en: "Repeat the passphrase to confirm it" }, "data.restore": { zh: "恢复…", en: "Restore…" }, + "data.restore_a_backup": { zh: "恢复备份", en: "Restore a backup" }, "data.restore_n_selected": { zh: "恢复所选 {n} 项", en: "Restore {n} selected" }, "data.restored_n_items": { zh: "已恢复 {n} 项。", en: "Restored {n} items." }, "data.restoring": { zh: "正在恢复…", en: "Restoring…" }, diff --git a/desktop/frontend-next/src/ui/BackupRestore.dialog.interaction.test.tsx b/desktop/frontend-next/src/ui/BackupRestore.dialog.interaction.test.tsx new file mode 100644 index 0000000000..21bc090e85 --- /dev/null +++ b/desktop/frontend-next/src/ui/BackupRestore.dialog.interaction.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { BackupRestore } from "./BackupRestore"; +import { MockPort } from "../port/mock"; +import type { AgentPort, BackupPlan } from "../port/port"; + +afterEach(cleanup); + +const plan = { + planId: "p1", platform: "windows/amd64", samePlatform: true, omitted: [], + items: [{ id: "i1", category: "settings", name: "general", status: "changed", summary: "default_model = x" }], +} as unknown as BackupPlan; + +function draw(over: Partial<{ preview: () => Promise; apply: () => Promise }> = {}) { + const onClose = vi.fn(); + const source = { + name: "公司笔记本", + preview: over.preview ?? (async () => plan), + apply: over.apply ?? (async () => ({ applied: ["i1"] })), + }; + render(); + return onClose; +} + +describe("restoring a backup", () => { + it("asks for the passphrase in a dialog with Cancel and a lock on Preview differences", async () => { + draw(); + const dialog = await screen.findByRole("dialog", { name: "恢复备份" }); + expect(dialog.textContent).toContain("输入备份「公司笔记本」的加密口令,先预览它和本机的差异,不会立即写入。"); + const foot = [...dialog.querySelectorAll("footer button")].map((b) => b.textContent); + expect(foot).toEqual(["取消", "预览差异"]); + expect(dialog.querySelector("footer .studio-icon")).toBeTruthy(); + expect((screen.getByRole("button", { name: "预览差异" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("closes from the cross, Cancel and Escape without previewing anything", async () => { + const preview = vi.fn(async () => plan); + const onClose = draw({ preview }); + await userEvent.click(await screen.findByRole("button", { name: "关闭" })); + await userEvent.click(await screen.findByRole("button", { name: "取消" })); + await userEvent.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalledTimes(3); + expect(preview).not.toHaveBeenCalled(); + }); + + it("reads the differences in the same dialog, wider, and offers the restore as the main action", async () => { + draw(); + await userEvent.type(await screen.findByLabelText(/口令/), "secret-pass"); + await userEvent.click(screen.getByRole("button", { name: "预览差异" })); + const dialog = await screen.findByRole("dialog", { name: "恢复备份" }); + await waitFor(() => expect(dialog.hasAttribute("data-wide") || dialog.querySelector(".bk-plan")).toBeTruthy()); + expect(dialog.textContent).toContain("general"); + expect(screen.getByRole("button", { name: /恢复所选/ }).hasAttribute("data-primary")).toBe(true); + }); + + it("says what came of the restore in one strip per outcome, in the page", async () => { + draw(); + await userEvent.type(await screen.findByLabelText(/口令/), "secret-pass"); + await userEvent.click(screen.getByRole("button", { name: "预览差异" })); + await userEvent.click(await screen.findByRole("checkbox")); + await userEvent.click(screen.getByRole("button", { name: /恢复所选/ })); + await waitFor(() => expect(document.querySelector(".bk-result")).toBeTruthy()); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.querySelector('.bk-strip[data-tone="ok"]')?.textContent).toContain("已恢复 1 项。"); + }); +}); diff --git a/desktop/frontend-next/src/ui/BackupRestore.tsx b/desktop/frontend-next/src/ui/BackupRestore.tsx index 1f44bc75dd..d87e26ba07 100644 --- a/desktop/frontend-next/src/ui/BackupRestore.tsx +++ b/desktop/frontend-next/src/ui/BackupRestore.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { t } from "../i18n"; import { reason } from "../i18n/kernel"; import type { AgentPort, BackupApplyResult, BackupCategory, BackupEntry, BackupPlan, BackupPlanItem, BackupPluginRef } from "../port/port"; import { AddPlugin } from "./AddPlugin"; +import { StudioIcon } from "./StudioIcon"; export const CATEGORY_LABEL: Record = { settings: "data.models_and_interface", @@ -26,6 +28,25 @@ const CONSENT_LABEL: Record = { imports: "data.i_have_read_this_instruction", }; +// A restore is asked for and read in a dialog; what it did is said afterwards, +// in the page, in one strip per outcome. +function RestoreDialog({ title, onClose, footer, wide, children }: { title: string; onClose: () => void; footer: ReactNode; wide?: boolean; children: ReactNode }) { + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }}> +
{ if (e.key === "Escape") { e.stopPropagation(); onClose(); } }}> +
+

{title}

+ +
+
{children}
+
{footer}
+
+
, + document.body, + ); +} + // The preview is the kernel's: which rows need consent, and why, is decided // there and rechecked on apply. This panel only collects two separate answers // per row — take it, and (where asked) allow what it does. @@ -100,35 +121,28 @@ export function BackupRestore({ port, source, onClose }: { port: AgentPort; sour if (result) { return ( -
-

{t("data.restored_n_items", { n: result.applied.length })}

- {result.failed?.map((f) => ( -

- {f.id}:{f.error} -

- ))} - {result.reloadError &&

{t("data.the_setup_was_written_but", { e: result.reloadError })}

} - {!!result.plugins?.length && ( - <> -

{t("data.these_plugins_go_through_the")}

-
    - {result.plugins.map((p) => ( -
  • -
    - {p.name} - {p.source}{p.version ? " · " + p.version : ""} -
    - -
  • - ))} -
- - )} -
- +
+
+ {t("data.restored_n_items", { n: result.applied.length })} +
+ {result.failed?.map((f) => ( +
{f.id}:{f.error}
+ ))} + {result.reloadError &&
{t("data.the_setup_was_written_but", { e: result.reloadError })}
} + {!!result.plugins?.length && ( +
+ {t("data.these_plugins_go_through_the")} + {result.plugins.map((p) => ( + + {p.name}{p.version ? " " + p.version : ""} + + + ))} +
+ )} {installing && ( setInstalling(null)} onInstalled={() => setInstalling((current) => current === installing ? null : current)} /> )} @@ -138,27 +152,33 @@ export function BackupRestore({ port, source, onClose }: { port: AgentPort; sour if (!plan) { return ( -
+ + + + }>

{t("data.enter_the_passphrase_for_name", { name: source.name })}

-
+
-
- - -
{error &&

{error}

} -
+
); } return ( -
+ + {blocked.length > 0 && {t("data.n_items_still_need_confirmation", { n: blocked.length })}} + + + }> {!plan.samePlatform && (
{t("data.this_backup_comes_from_p", { p: plan.platform })} @@ -195,14 +215,7 @@ export function BackupRestore({ port, source, onClose }: { port: AgentPort; sour ))} {!!plan.omitted?.length &&

{t("data.n_files_were_too_large", { n: plan.omitted.length })}

} -
- - - {blocked.length > 0 && {t("data.n_items_still_need_confirmation", { n: blocked.length })}} -
{error &&

{error}

} -
+
); }