From 765d4e1ef82dadce64c390dc4e058f4b5335d928 Mon Sep 17 00:00:00 2001 From: auYe Date: Fri, 9 Oct 2026 07:22:22 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20=E8=AF=A6=E6=83=85=E5=8D=A0?= =?UTF-8?q?=E6=BB=A1=E5=AE=BD=E5=BA=A6=E5=B9=B6=E5=8A=A0=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E4=B8=8E=E5=85=A5=E5=9C=BA=E5=8A=A8=E6=95=88,=20=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=95=B4=E8=A1=8C=E6=82=AC=E5=81=9C=E9=AB=98=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 条目详情的字段列表与附件区去掉宽度上限, 横向占满窗格 - 列表项悬停底色由行内按钮移到整行, 覆盖勾选框 - 再次点击已选中的条目不重新读取详情, 读取失败的条目仍可重试 - 读取详情期间显示骨架, 新增 Skeleton 组件与读屏文案 - 详情入场改为淡入并上移到位, 新增 CONTENT_ENTER_MOTION 消除详情窗格的留白, 重复加载与生硬切换, 让列表悬停反馈完整 --- src/renderer/src/components/ui/skeleton.tsx | 19 +++++++++ .../src/components/ui/state-motion.ts | 7 ++++ .../attachment-drop-zone.tsx | 2 +- .../entry-detail-pane.test.tsx | 18 +++++++++ .../entry-detail-pane/entry-detail-pane.tsx | 9 +++-- .../entry-detail-skeleton.tsx | 40 +++++++++++++++++++ .../entry-detail-view-motion.test.tsx | 10 ++--- .../entry-detail-pane/entry-detail-view.tsx | 9 +++-- .../entry-list-pane/entry-list-item.tsx | 21 +++++++--- .../src/stores/entry-store-actions.ts | 18 +++++++++ src/renderer/src/stores/entry-store.test.ts | 26 ++++++++++++ src/renderer/src/stores/entry-store.ts | 8 ++-- .../src/styles/app-motion-coverage.test.ts | 6 ++- src/shared/locales/en.json | 1 + src/shared/locales/zh.json | 1 + 15 files changed, 171 insertions(+), 24 deletions(-) create mode 100644 src/renderer/src/components/ui/skeleton.tsx create mode 100644 src/renderer/src/features/entry-detail-pane/entry-detail-skeleton.tsx 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..de49986 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 @@ -82,6 +82,24 @@ describe("EntryDetailPane 展示", () => { expect(screen.queryByText("forum-password")).toBeNull(); }); + it("读取详情期间显示骨架, 读取完成后换成详情", async () => { + const environment = await createEntryTestEnvironment({ + entries: TEST_ENTRIES, + }); + render(, { wrapper: environment.Providers }); + + const pending = environment.entryStore.getState().select("forum"); + + expect( + await screen.findByRole("status", { name: "正在读取条目详情" }), + ).toBeDefined(); + await pending; + expect(await screen.findByRole("heading", { name: "论坛" })).toBeDefined(); + expect( + screen.queryByRole("status", { name: "正在读取条目详情" }), + ).toBeNull(); + }); + it("读取详情失败时说明原因", async () => { await renderPane("forum", { entries: TEST_ENTRIES, 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": { From 9b689ff136fc1e501c23e69f53e284ef285c0693 Mon Sep 17 00:00:00 2001 From: auYe Date: Fri, 9 Oct 2026 07:22:40 -0700 Subject: [PATCH 2/3] =?UTF-8?q?chore(release):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8D=87=E8=87=B3=201.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json 与 package-lock.json 的 version 由 1.0.0 改为 1.1.0 为打 v1.1.0 标签发布做准备, 标签须与 package.json 的版本一致 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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", From 41ac3bc58202cd7fdc745eccb1cc34cd93afb4eb Mon Sep 17 00:00:00 2001 From: auYe Date: Fri, 9 Oct 2026 07:24:40 -0700 Subject: [PATCH 3/3] =?UTF-8?q?test(ui):=20=E9=AA=A8=E6=9E=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=94=B9=E7=94=A8=E5=8F=AF=E6=8E=A7=E7=9A=84=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E8=AF=BB=E5=8F=96=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情读取由测试手动放行, 骨架出现后再放行, 不再依赖异步先后 - 读取中与读取失败的用例拆到单独的分组 避免骨架断言在较慢的持续集成机器上偶发失败 --- .../entry-detail-pane/entry-detail-pane.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 de49986..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,10 +81,21 @@ 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 }); @@ -93,6 +104,7 @@ describe("EntryDetailPane 展示", () => { expect( await screen.findByRole("status", { name: "正在读取条目详情" }), ).toBeDefined(); + releaseDetail(); await pending; expect(await screen.findByRole("heading", { name: "论坛" })).toBeDefined(); expect(