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 (
+
+
+
+
+
+
+
+ {FIELD_VALUE_WIDTH_CLASS_NAMES.map((widthClassName) => (
+
+
+
+
+ ))}
+
+
+ );
+}
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": {