diff --git a/package-lock.json b/package-lock.json index 9c84410..8f60c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "radish-security-notes", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "radish-security-notes", - "version": "1.0.0", + "version": "1.1.0", "hasInstallScript": true, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/package.json b/package.json index 2e5f819..5692485 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "radish-security-notes", - "version": "1.0.0", + "version": "1.1.0", "description": "本地加密的桌面密码与笔记保险库 (Electron, React, TypeScript)", "main": "./out/main/index.js", "author": "auYe", diff --git a/src/renderer/src/components/ui/skeleton.tsx b/src/renderer/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..1a1caaf --- /dev/null +++ b/src/renderer/src/components/ui/skeleton.tsx @@ -0,0 +1,19 @@ +import { cn } from "cn"; + +/** + * 骨架占位块, 内容读取期间按内容的大致形状占位, 底色缓慢明暗变化. + * @param root0 组件属性, 含 className 与块级元素原生属性. + * @returns 占位块元素. + */ +export function Skeleton({ + className, + ...props +}: React.ComponentProps<"div">): React.JSX.Element { + return ( +
+ ); +} diff --git a/src/renderer/src/components/ui/state-motion.ts b/src/renderer/src/components/ui/state-motion.ts index 96db8c5..88feb09 100644 --- a/src/renderer/src/components/ui/state-motion.ts +++ b/src/renderer/src/components/ui/state-motion.ts @@ -30,3 +30,10 @@ export const MARK_TRANSITION = */ export const FADE_IN_MOTION = "animate-in fade-in-0 duration-(--motion-fast) ease-(--motion-ease)"; + +/** + * 基础档内容入场: 元素挂载时淡入并自下方上移一小段到位, 用于条目详情这类整块换入的主要内容, + * 比快档淡入更明显. 消失时直接卸载, 不做动画. + */ +export const CONTENT_ENTER_MOTION = + "animate-in fade-in-0 slide-in-from-bottom-2 duration-(--motion-base) ease-(--motion-ease)"; diff --git a/src/renderer/src/features/entry-attachments/attachment-drop-zone.tsx b/src/renderer/src/features/entry-attachments/attachment-drop-zone.tsx index 1e150b3..d621271 100644 --- a/src/renderer/src/features/entry-attachments/attachment-drop-zone.tsx +++ b/src/renderer/src/features/entry-attachments/attachment-drop-zone.tsx @@ -52,7 +52,7 @@ export function AttachmentDropZone( aria-label={props.label} className={cn( FAST_STATE_TRANSITION, - "flex max-w-xl flex-col gap-3 rounded-lg border border-dashed p-3", + "flex flex-col gap-3 rounded-lg border border-dashed p-3", isDragging ? "border-primary bg-muted" : "border-border", )} {...handlers} diff --git a/src/renderer/src/features/entry-detail-pane/entry-detail-pane.test.tsx b/src/renderer/src/features/entry-detail-pane/entry-detail-pane.test.tsx index 9da4e2c..ce84578 100644 --- a/src/renderer/src/features/entry-detail-pane/entry-detail-pane.test.tsx +++ b/src/renderer/src/features/entry-detail-pane/entry-detail-pane.test.tsx @@ -81,6 +81,36 @@ describe("EntryDetailPane 展示", () => { await user.click(screen.getByRole("button", { name: "隐藏 密码" })); expect(screen.queryByText("forum-password")).toBeNull(); }); +}); + +describe("EntryDetailPane 读取中与读取失败", () => { + it("读取详情期间显示骨架, 读取完成后换成详情", async () => { + let releaseDetail: () => void = () => undefined; + const environment = await createEntryTestEnvironment({ + entries: TEST_ENTRIES, + entryBridgeOverrides: { + get: async () => { + await new Promise((resolve) => { + releaseDetail = resolve; + }); + return { ok: true, value: FORUM_ENTRY }; + }, + }, + }); + render(, { wrapper: environment.Providers }); + + const pending = environment.entryStore.getState().select("forum"); + + expect( + await screen.findByRole("status", { name: "正在读取条目详情" }), + ).toBeDefined(); + releaseDetail(); + await pending; + expect(await screen.findByRole("heading", { name: "论坛" })).toBeDefined(); + expect( + screen.queryByRole("status", { name: "正在读取条目详情" }), + ).toBeNull(); + }); it("读取详情失败时说明原因", async () => { await renderPane("forum", { diff --git a/src/renderer/src/features/entry-detail-pane/entry-detail-pane.tsx b/src/renderer/src/features/entry-detail-pane/entry-detail-pane.tsx index 4a118cc..354cce4 100644 --- a/src/renderer/src/features/entry-detail-pane/entry-detail-pane.tsx +++ b/src/renderer/src/features/entry-detail-pane/entry-detail-pane.tsx @@ -7,6 +7,7 @@ import type { EntryDetail } from "@shared/entries/entry-types"; import type { EntrySelection } from "@renderer/stores/entry-state"; import { useEntryStore } from "@renderer/stores/use-entry-store"; +import { EntryDetailSkeleton } from "./entry-detail-skeleton"; import { EntryDetailView } from "./entry-detail-view"; /** @@ -45,25 +46,25 @@ function CenteredMessage(props: CenteredMessageProps): React.JSX.Element { } /** - * 按选中状态选出详情窗格的内容: 没有选中时提示选择, 读取中不显示内容, 读取失败时说明原因, + * 按选中状态选出详情窗格的内容: 没有选中时提示选择, 读取中显示骨架, 读取失败时说明原因, * 读取完成后显示详情. 详情视图以条目编号与编辑次数作 key, 切换条目或保存编辑后重新挂载. * @param selection 当前选中状态. * @param revision 条目被编辑保存的次数. * @param translate 翻译函数. * @param renderers 生成标题行右侧操作与备注之后附件区的函数. - * @returns 窗格内容, 读取中时为 null. + * @returns 窗格内容. */ function renderSelection( selection: EntrySelection, revision: number, translate: TFunction, renderers: Pick, -): React.JSX.Element | null { +): React.JSX.Element { switch (selection.status) { case "none": return ; case "loading": - return null; + return ; case "failed": return ( diff --git a/src/renderer/src/features/entry-detail-pane/entry-detail-skeleton.tsx b/src/renderer/src/features/entry-detail-pane/entry-detail-skeleton.tsx new file mode 100644 index 0000000..06651f2 --- /dev/null +++ b/src/renderer/src/features/entry-detail-pane/entry-detail-skeleton.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; + +import { Skeleton } from "@renderer/components/ui/skeleton"; +import { FADE_IN_MOTION } from "@renderer/components/ui/state-motion"; +import { cn } from "@renderer/lib/class-names"; + +/** + * 骨架里各字段值占位块的宽度类名, 长短错开, 一项对应一行字段. + */ +const FIELD_VALUE_WIDTH_CLASS_NAMES = ["w-56", "w-32", "w-64", "w-40"] as const; + +/** + * 条目详情读取期间的骨架: 按详情的版式摆出类型, 名称, 文件夹与几行字段的占位块, 间距与详情视图 + * 一致, 读取完成后内容原位替换. 占位块本身对读屏软件隐藏, 整体以状态区域通知正在读取. + * @returns 骨架元素. + */ +export function EntryDetailSkeleton(): React.JSX.Element { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} diff --git a/src/renderer/src/features/entry-detail-pane/entry-detail-view-motion.test.tsx b/src/renderer/src/features/entry-detail-pane/entry-detail-view-motion.test.tsx index b0f47f1..63404ef 100644 --- a/src/renderer/src/features/entry-detail-pane/entry-detail-view-motion.test.tsx +++ b/src/renderer/src/features/entry-detail-pane/entry-detail-view-motion.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { FADE_IN_MOTION } from "@renderer/components/ui/state-motion"; +import { CONTENT_ENTER_MOTION } from "@renderer/components/ui/state-motion"; import { TEST_ENTRIES } from "@renderer/testing/entry-fixtures"; import { createEntryTestEnvironment, @@ -51,13 +51,13 @@ function detailRootOf(container: HTMLElement): Element | null { } describe("条目详情内容的切换动效", () => { - it("详情内容挂载时快档淡入", async () => { + it("详情内容挂载时淡入并上移到位", async () => { const { container } = await renderPane("forum"); - expectMotionClasses(detailRootOf(container), FADE_IN_MOTION); + expectMotionClasses(detailRootOf(container), CONTENT_ENTER_MOTION); }); - it("切换到另一个条目时换成新的详情元素, 新元素同样淡入", async () => { + it("切换到另一个条目时换成新的详情元素, 新元素同样入场", async () => { const { environment, container } = await renderPane("forum"); const previousRoot = detailRootOf(container); @@ -66,7 +66,7 @@ describe("条目详情内容的切换动效", () => { await screen.findByRole("heading", { name: "银行" }); const nextRoot = detailRootOf(container); expect(nextRoot).not.toBe(previousRoot); - expectMotionClasses(nextRoot, FADE_IN_MOTION); + expectMotionClasses(nextRoot, CONTENT_ENTER_MOTION); }); it("没有选中条目时的提示文字不淡入", async () => { diff --git a/src/renderer/src/features/entry-detail-pane/entry-detail-view.tsx b/src/renderer/src/features/entry-detail-pane/entry-detail-view.tsx index ee88133..38c8126 100644 --- a/src/renderer/src/features/entry-detail-pane/entry-detail-view.tsx +++ b/src/renderer/src/features/entry-detail-pane/entry-detail-view.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { NOTES_FIELD_KEY, type EntryDetail } from "@shared/entries/entry-types"; import { entryFieldName } from "@renderer/components/entry-type-naming"; -import { FADE_IN_MOTION } from "@renderer/components/ui/state-motion"; +import { CONTENT_ENTER_MOTION } from "@renderer/components/ui/state-motion"; import { cn } from "@renderer/lib/class-names"; import { useEntryStore } from "@renderer/stores/use-entry-store"; import { useEntryTypeCatalog } from "@renderer/stores/use-entry-type-catalog"; @@ -39,7 +39,8 @@ interface EntryDetailViewProps { * 类型的字段, 带 TOTP 时的验证码与 TOTP 密钥, 自定义字段与备注 (按备注格式呈现), 最后是调用方给出的附件区, 敏感 * 字段默认遮罩, 每项带复制按钮. 复制由主进程写入剪贴板. 条目的类型 (预设或自定义) 经类型目录取得, * 目录里没有这个类型时不渲染. 调用方用条目编号与编辑次数作 key, 切换条目或保存编辑后遮罩字段 - * 的显示状态随之恢复为遮罩, 验证码与已显示的密钥随组件卸载而丢弃. 每次挂载时内容快档淡入. + * 的显示状态随之恢复为遮罩, 验证码与已显示的密钥随组件卸载而丢弃. 每次挂载时内容淡入并自下方 + * 上移到位. * @param props 组件属性. * @returns 详情视图元素, 条目的类型不在目录里时为 null. */ @@ -55,9 +56,9 @@ export function EntryDetailView( return null; } return ( -
+
-
+
{type.fields.map((field) => ( { + const { selection } = access.get(); + if (selection.status !== "failed" && selectedIdOf(selection) === id) { + return; + } + await selectEntry(access, id); +} + /** * 新建一个条目. 成功后条目放到 store 数组最前 (显示位置由名称排序规则决定), 被选中并展示详情, 搜索关键字清空, 入口跟随条目 (新条目 * 不属于当前入口时切到它所属的文件夹), 让新条目一定出现在列表里. diff --git a/src/renderer/src/stores/entry-store.test.ts b/src/renderer/src/stores/entry-store.test.ts index ca13a69..b3d7a4b 100644 --- a/src/renderer/src/stores/entry-store.test.ts +++ b/src/renderer/src/stores/entry-store.test.ts @@ -97,6 +97,32 @@ describe("条目 store 选中", () => { id: "missing", }); }); + + it("再次选中已经选中的条目时不重新读取, 详情保持原样", async () => { + const bridge = createFakeEntryBridge([FORUM, BANK]); + const get = vi.fn(bridge.get); + bridge.get = get; + const store = createEntryStore({ bridge }); + await store.getState().select("forum"); + const selection = store.getState().selection; + + await store.getState().select("forum"); + + expect(get).toHaveBeenCalledTimes(1); + expect(store.getState().selection).toBe(selection); + }); + + it("上一次读取失败的条目再次选中时重新读取", async () => { + const bridge = createFakeEntryBridge(); + const get = vi.fn(bridge.get); + bridge.get = get; + const store = createEntryStore({ bridge }); + await store.getState().select("missing"); + + await store.getState().select("missing"); + + expect(get).toHaveBeenCalledTimes(2); + }); }); describe("条目 store 选中竞争", () => { diff --git a/src/renderer/src/stores/entry-store.ts b/src/renderer/src/stores/entry-store.ts index 31ffc8c..0959f1c 100644 --- a/src/renderer/src/stores/entry-store.ts +++ b/src/renderer/src/stores/entry-store.ts @@ -14,7 +14,7 @@ import { copyEntryField, createEntry, loadEntries, - selectEntry, + selectEntryIfChanged, } from "./entry-store-actions"; import { applyBatchFolder, @@ -43,9 +43,9 @@ export interface EntryActions { */ load: () => Promise; /** - * 选中一个条目并读取它的详情. + * 选中一个条目并读取它的详情. 条目已经是选中的 (读取完成或正在读取) 时不重新读取. * @param id 条目编号. - * @returns 详情读取完成后兑现. + * @returns 详情读取完成后兑现, 不需要读取时立即兑现. */ select: (id: string) => Promise; /** @@ -187,7 +187,7 @@ export function createEntryStore( return { ...INITIAL_ENTRY_STATE, load: () => loadEntries(access), - select: (id) => selectEntry(access, id), + select: (id) => selectEntryIfChanged(access, id), setQuery: (query) => applyQuery(access, query), search: () => runEntrySearch(access), create: (input) => createEntry(access, input), diff --git a/src/renderer/src/styles/app-motion-coverage.test.ts b/src/renderer/src/styles/app-motion-coverage.test.ts index 5122b3a..b61fb01 100644 --- a/src/renderer/src/styles/app-motion-coverage.test.ts +++ b/src/renderer/src/styles/app-motion-coverage.test.ts @@ -38,7 +38,11 @@ const APP_COMPONENT_COVERAGE: readonly (readonly [ ["components/totp-code-button.tsx", ["FADE_IN_MOTION"]], ["components/copy-button.tsx", ["FADE_IN_MOTION"]], ["features/entry-batch-bar/batch-bar-actions.tsx", ["FADE_IN_MOTION"]], - ["features/entry-detail-pane/entry-detail-view.tsx", ["FADE_IN_MOTION"]], + [ + "features/entry-detail-pane/entry-detail-view.tsx", + ["CONTENT_ENTER_MOTION"], + ], + ["features/entry-detail-pane/entry-detail-skeleton.tsx", ["FADE_IN_MOTION"]], ]; /** diff --git a/src/shared/locales/en.json b/src/shared/locales/en.json index 7e839c9..2cc188e 100644 --- a/src/shared/locales/en.json +++ b/src/shared/locales/en.json @@ -154,6 +154,7 @@ "entryDetailPane": { "heading": "Entry details", "empty": "Select an entry to see its details", + "loading": "Loading entry details", "loadFailed": "Unable to load the details of this entry." }, "entryDetail": { diff --git a/src/shared/locales/zh.json b/src/shared/locales/zh.json index bb18005..306544f 100644 --- a/src/shared/locales/zh.json +++ b/src/shared/locales/zh.json @@ -154,6 +154,7 @@ "entryDetailPane": { "heading": "条目详情", "empty": "选择一个条目查看详情", + "loading": "正在读取条目详情", "loadFailed": "无法读取这个条目的详情." }, "entryDetail": {