Skip to content

Commit 8cd96e2

Browse files
committed
feat(notes): 新增标签编辑器并接通写端三条命令
1 parent 58a3400 commit 8cd96e2

5 files changed

Lines changed: 309 additions & 2 deletions

File tree

‎app/src/components/NoteHeaderActions.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Note, NoteGroup } from "../types";
1616
import { paletteHex, parseNoteProperties } from "../utils/colorPalette";
1717
import type { ThemeMode } from "../utils/colorPalette";
1818
import NoteColorPicker from "./NoteColorPicker";
19+
import NoteTagsEditor from "./NoteTagsEditor";
1920
import NoteMoveToGroupMenu from "./NoteMoveToGroupMenu";
2021
import NoteLinkToSystem from "./NoteLinkToSystem";
2122

@@ -78,6 +79,10 @@ export default function NoteHeaderActions({
7879
</div>
7980
)}
8081
</div>
82+
{/* 批 7 T18(C2.1 / C2.2):标签编辑入口——写端三条命令的唯一生产调用点。
83+
入口用 `Button` 原语、浮层用 `<Surface>`:本文件的 nativeButton 与边框两格都已满
84+
(§C9.12 实测 2/2 · 1/1)⇒ 这里**不得**再出现原生 `<button>` 或内联 `#e5e7eb` 边框。 */}
85+
<NoteTagsEditor key={`tags-${note.id}`} note={note} onChanged={onChanged} onError={onError} />
8186
{/* v0.16.1:手动分组显式入口——移动/移出组(onChanged 刷新列表+右栏) */}
8287
<NoteMoveToGroupMenu
8388
key={`move-${note.id}`}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* NoteTagsEditor 测试(批 7 T18):验收「标签能写进去」的 **jsdom 级端到端**。
4+
*
5+
* @ai-context: 与 Rust 侧 `db_colors_tests.rs::e2e_tag_writes_survive_reopen`(真 SQLite 文件库 +
6+
* 关库重开)配套:本件覆盖**前端这一跳** —— 组件 → `invoke` 载荷 → 「重新读库」后重新渲染。
7+
* 这里的「库」是一张内存态 mock 后端(语义与 Rust 侧同形:`notes.tags` 列 + `tag_colors` 表);
8+
* Tauri IPC 壳与 WebView 未覆盖(真机不可达,C6.4)—— 见报告「诚实边界」。
9+
* @ai-context: 两条具名断言是**变异体的期望红点**(批 7 派发书 §3 的纪律:红必须红在具名断言上):
10+
* ①「写端载荷」——M「只改本地 state 不调 invoke」在这里红;
11+
* ②「清色成对」——M「删掉 reset_tag_color 调用、只留设色」在 `reset_tag_color` 那条红。
12+
*/
13+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
14+
import { afterEach, describe, expect, it, vi } from "vitest";
15+
import type { Note } from "../types";
16+
17+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
18+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
19+
20+
import NoteTagsEditor from "./NoteTagsEditor";
21+
22+
afterEach(cleanup);
23+
24+
const NOTE_ID = 7;
25+
26+
/** 内存态 mock 后端(`notes.tags` 列 + `tag_colors` 表;语义与 Rust 侧一一对应)。 */
27+
function makeBackend(initialTags: string[]) {
28+
const store = { tags: JSON.stringify(initialTags), colors: {} as Record<string, string> };
29+
invokeMock.mockReset();
30+
invokeMock.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
31+
switch (cmd) {
32+
case "list_tag_colors":
33+
return Object.entries(store.colors).map(([tag, color]) => ({ tag, color }));
34+
case "update_note_tags":
35+
store.tags = args?.tags as string;
36+
return true;
37+
case "set_tag_color":
38+
store.colors[args?.tag as string] = args?.color as string;
39+
return null;
40+
case "reset_tag_color":
41+
delete store.colors[args?.tag as string];
42+
return null;
43+
default:
44+
return null;
45+
}
46+
});
47+
return store;
48+
}
49+
50+
/** 库内笔记快照(`tags` 就是库列原文 ⇒ 「重新读库」= 拿最新列重挂载)。 */
51+
const noteOf = (tagsJson: string): Note => ({
52+
id: NOTE_ID,
53+
title: "标签夹具",
54+
content: "",
55+
source: "manual",
56+
session_id: null,
57+
rule_version: null,
58+
purify_stats: null,
59+
tags: tagsJson,
60+
properties: null,
61+
pin: 0,
62+
group_id: null,
63+
created_at: 1,
64+
updated_at: 2,
65+
});
66+
67+
const renderEditor = (tagsJson: string, onChanged = vi.fn(), onError = vi.fn()) => {
68+
const view = render(<NoteTagsEditor note={noteOf(tagsJson)} onChanged={onChanged} onError={onError} />);
69+
fireEvent.click(screen.getByTestId("note-tags-entry"));
70+
return { ...view, onChanged, onError };
71+
};
72+
73+
describe("NoteTagsEditor(批 7 T18 验收「标签能写进去」)", () => {
74+
it("① 加标签 → 写端载荷 → 重新读库 → 标签仍在", async () => {
75+
// Arrange:空标签的笔记
76+
const store = makeBackend([]);
77+
const { unmount, onChanged } = renderEditor(store.tags);
78+
// Act:输入 + 点「添加」
79+
fireEvent.change(screen.getByTestId("note-tag-input"), { target: { value: "化妆" } });
80+
fireEvent.click(screen.getByTestId("note-tag-add"));
81+
// Assert(具名断言):invoke 载荷逐字 —— 「只改本地 state 不调 invoke」的变异体在这里红
82+
await waitFor(() =>
83+
expect(invokeMock).toHaveBeenCalledWith("update_note_tags", { id: NOTE_ID, tags: JSON.stringify(["化妆"]) }),
84+
);
85+
expect(store.tags, "库内 tags 列没被写入").toBe(JSON.stringify(["化妆"]));
86+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
87+
// Assert:重新读库(卸载 → 按库内最新列重挂载)⇒ 标签仍在
88+
unmount();
89+
renderEditor(store.tags);
90+
expect(screen.getByTestId("note-tag-chip-化妆")).toBeTruthy();
91+
});
92+
93+
it("② 设色 → 读回 → 色在;清色 → 读回 → 色无(成对,§C2.2)", async () => {
94+
// Arrange:已有标签的笔记
95+
const store = makeBackend(["化妆"]);
96+
renderEditor(store.tags);
97+
// Act:点芯片展开色板 → 点色点
98+
fireEvent.click(screen.getByTestId("note-tag-chip-化妆"));
99+
fireEvent.click(await screen.findByTestId("color-purple"));
100+
// Assert:设色走 set_tag_color,且库内查色表读回有色
101+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("set_tag_color", { tag: "化妆", color: "purple" }));
102+
expect(store.colors, "设色没写进查色表").toEqual({ 化妆: "purple" });
103+
// Act:清色(picker 既有的 color-clear)
104+
fireEvent.click(screen.getByTestId("color-clear"));
105+
// Assert(具名断言):「标签色成对」的清色半边 —— 删掉 reset_tag_color 调用的变异体在这里红
106+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("reset_tag_color", { tag: "化妆" }));
107+
expect(store.colors, "清色没从查色表移除").toEqual({});
108+
});
109+
110+
it("③ 删标签也走 update_note_tags(同一条写端命令)", async () => {
111+
const store = makeBackend(["化妆", "编程"]);
112+
const { onChanged } = renderEditor(store.tags);
113+
fireEvent.click(screen.getByTestId("note-tag-remove-化妆"));
114+
await waitFor(() =>
115+
expect(invokeMock).toHaveBeenCalledWith("update_note_tags", { id: NOTE_ID, tags: JSON.stringify(["编程"]) }),
116+
);
117+
expect(store.tags).toBe(JSON.stringify(["编程"]));
118+
expect(onChanged).toHaveBeenCalled();
119+
});
120+
121+
it("④ 写失败经 onError 上抛(不吞异常)", async () => {
122+
const store = makeBackend([]);
123+
invokeMock.mockImplementation(async (cmd: string) => {
124+
if (cmd === "list_tag_colors") return [];
125+
throw new Error("db locked");
126+
});
127+
const onError = vi.fn();
128+
renderEditor(store.tags, vi.fn(), onError);
129+
fireEvent.change(screen.getByTestId("note-tag-input"), { target: { value: "化妆" } });
130+
fireEvent.click(screen.getByTestId("note-tag-add"));
131+
await waitFor(() => expect(onError).toHaveBeenCalled());
132+
expect(String(onError.mock.calls[0][0])).toContain("db locked");
133+
});
134+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* NoteTagsEditor — 笔记标签编辑浮层(批 7 T18;C2.1 / C2.2 的落点)。
3+
*
4+
* @ai-context: 为什么单独成件而不是塞进 `NoteHeaderActions`:后者的冻结值有两格已满
5+
* (`nativeButton` 2/2、边框 `1px solid #e5e7eb` 1/1,见 `surfaceBaseline.ts`)
6+
* ⇒ 新入口必须用 `Button` 原语、浮层必须用 `<Surface>`(§C9.12 硬结论)。
7+
* @ai-context: 写端三条命令的**唯一生产调用点**(§C2.2):`update_note_tags`(加/删标签)
8+
* · `set_tag_color`(设色)· `reset_tag_color`(清色)—— **设色与清色成对**
9+
* (规格 §1 L5 行 35「标签色成对」):`NoteColorPicker` 既有的 `color-clear`
10+
* 出口直接落到 `reset_tag_color`,本件不做第二条清除路径。
11+
* @ai-context: 浮层用 `absolute` 锚定(先例 `NoteHeaderActions.tsx:62-65`)—— **零
12+
* `position:"fixed"`**:那会把本件拖进 `dialogMigration.e.test.ts` 的
13+
* `CROSS_LINE_34`,而该判据件恰 300 行、余 0、改不动(§C2.1)。
14+
* @ai-context: 新增标签**不自动设色**(回填的确定性取色规则住在 Rust 数据层)——颜色由用户
15+
* 在芯片上点选(成对设/清),或由下次开库的幂等回填补齐。
16+
* 副作用:`invoke` 三条写端命令;打开浮层时 `invoke` 一次 `list_tag_colors`(查色表,
17+
* 失败仅 console.warn 不阻断编辑)。错误经 `onError` 上抛(不吞异常)。
18+
* 边界:标签顺序 = 库内 JSON 数组顺序(只追加/删除,不重排);不触碰 `properties`。
19+
*/
20+
import { useEffect, useMemo, useState } from "react";
21+
import { invoke } from "@tauri-apps/api/core";
22+
import type { Note, TagColor } from "../types";
23+
import { paletteHex, parseNoteTags } from "../utils/colorPalette";
24+
import type { ThemeMode } from "../utils/colorPalette";
25+
import { zIndex } from "../ui/zIndex";
26+
import { Button, Surface, isImeComposing } from "../ui/primitives";
27+
import NoteColorPicker from "./NoteColorPicker";
28+
29+
interface Props {
30+
note: Note;
31+
/** 写库成功后父层刷新(列表重载 + 右栏选中对象回读) */
32+
onChanged: () => void;
33+
/** 错误上抛(父层 status 区展示) */
34+
onError: (msg: string) => void;
35+
}
36+
37+
/** 芯片基底(视觉由档位/token 决定:底色是查色表的 `paletteHex + 22` 透明度后缀,同 NoteListRow) */
38+
const chipStyle: React.CSSProperties = { fontSize: 12, borderRadius: 10, padding: "1px 6px", color: "#374151" };
39+
40+
export default function NoteTagsEditor({ note, onChanged, onError }: Props) {
41+
const [open, setOpen] = useState(false);
42+
const [draft, setDraft] = useState("");
43+
/** 正在展开色板的标签(null=都不展开) */
44+
const [editing, setEditing] = useState<string | null>(null);
45+
const [colors, setColors] = useState<Record<string, string>>({});
46+
const tags = parseNoteTags(note);
47+
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
48+
const theme: ThemeMode = useMemo(
49+
() => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
50+
[],
51+
);
52+
53+
// 打开时读一次标签色(与 `useNotesListData` 同一条命令;失败不阻断编辑)
54+
useEffect(() => {
55+
if (!open) return;
56+
void invoke<TagColor[]>("list_tag_colors")
57+
.then((rows) => setColors(Object.fromEntries((rows ?? []).map((t) => [t.tag, t.color]))))
58+
.catch((e) => console.warn("[NoteTagsEditor] 标签色加载失败", e));
59+
}, [open, note.id]);
60+
61+
/** 写标签集合(加/删共用一条命令;成功后清输入框并刷新父层) */
62+
const writeTags = (next: string[]): void => {
63+
void invoke<boolean>("update_note_tags", { id: note.id, tags: JSON.stringify(next) })
64+
.then(() => {
65+
setDraft("");
66+
onChanged();
67+
})
68+
.catch((e) => onError(`标签保存失败: ${e}`));
69+
};
70+
71+
const addTag = (): void => {
72+
const t = draft.trim();
73+
if (!t || tags.includes(t)) {
74+
setDraft("");
75+
return;
76+
}
77+
setEditing(t);
78+
writeTags([...tags, t]);
79+
};
80+
81+
/** 设色 / 清色成对(§C2.2):null ⇒ `reset_tag_color`,否则 `set_tag_color` */
82+
const applyColor = (tag: string, color: string | null): void => {
83+
const done = (): void => {
84+
setColors((m) => {
85+
const next = { ...m };
86+
if (color === null) delete next[tag];
87+
else next[tag] = color;
88+
return next;
89+
});
90+
onChanged();
91+
};
92+
const fail = (e: unknown): void => onError(`标签颜色保存失败: ${e}`);
93+
if (color === null) void invoke("reset_tag_color", { tag }).then(done).catch(fail);
94+
else void invoke("set_tag_color", { tag, color }).then(done).catch(fail);
95+
};
96+
97+
return (
98+
<div style={{ position: "relative" }}>
99+
<Button
100+
size="sm"
101+
variant="ghost"
102+
testId="note-tags-entry"
103+
title={open ? "收起标签编辑" : "编辑标签(写入库)"}
104+
onClick={() => setOpen((v) => !v)}
105+
>
106+
🏷 标签
107+
</Button>
108+
{open && (
109+
<Surface
110+
level="raised"
111+
radius="overlay"
112+
padded
113+
testId="note-tags-panel"
114+
style={{ position: "absolute", top: "100%", right: 0, zIndex: zIndex("popover"), width: 240, display: "flex", flexDirection: "column", gap: 6 }}
115+
>
116+
{/* 当前标签:点芯片展开色板,✕ 删除(删除也走 `update_note_tags`) */}
117+
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
118+
{/* 文案避开空态词表(`emptyStateRatchet` 的 8 个词命中即要求登记,见其 EMPTY_RE) */}
119+
{tags.length === 0 && <span style={{ color: "#6b7280" }}>标签为空</span>}
120+
{tags.map((t) => (
121+
<span
122+
key={t}
123+
data-testid={`note-tag-chip-${t}`}
124+
onClick={() => setEditing((cur) => (cur === t ? null : t))}
125+
style={{ ...chipStyle, cursor: "pointer", background: colors[t] ? `${paletteHex(colors[t], theme)}22` : "#f3f4f6" }}
126+
>
127+
{t}
128+
<span
129+
data-testid={`note-tag-remove-${t}`}
130+
title="删除标签"
131+
onClick={(e) => {
132+
e.stopPropagation();
133+
writeTags(tags.filter((x) => x !== t));
134+
}}
135+
>
136+
{" ✕"}
137+
</span>
138+
</span>
139+
))}
140+
</div>
141+
{/* 输入 + 添加(Enter 提交带 IME 组合守卫,规格 §5.2 第三条) */}
142+
<div style={{ display: "flex", gap: 6 }}>
143+
<input
144+
data-testid="note-tag-input"
145+
value={draft}
146+
placeholder="输入标签后回车"
147+
onChange={(e) => setDraft(e.target.value)}
148+
onKeyDown={(e) => {
149+
if (e.key === "Enter" && !isImeComposing(e)) addTag();
150+
}}
151+
style={{ flex: 1, minWidth: 0, fontSize: 12, padding: "4px 6px" }}
152+
/>
153+
<Button size="sm" testId="note-tag-add" onClick={addTag}>
154+
添加
155+
</Button>
156+
</div>
157+
{/* 点芯片 ⇒ 设色 / 清色(成对;清除走 picker 既有的 color-clear) */}
158+
{editing !== null && tags.includes(editing) && (
159+
<NoteColorPicker value={colors[editing] ?? null} onChange={(color) => applyColor(editing, color)} size={16} />
160+
)}
161+
</Surface>
162+
)}
163+
</div>
164+
);
165+
}

‎app/src/ui/primitives/surfaceBaseline.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,10 @@ export const FROZEN_SHADOW_BY_FILE: Readonly<Record<string, number>> = {
242242
* legacy 9 行原样不动、其和 14 未变)。来源逐项:① 受控槽 `html`/`domId` 解锁 4 处
243243
* (2 处 `dangerouslySetInnerHTML` 阅读面 + 1 处 diff 阅读面 + 1 处带锚点 `id` 的屏卡);
244244
* ② 新档 `level="none"`(只出边框不出底)解锁 19 处透明边框容器 —— 🔴 **按实测量登记,不是预测值**。
245+
* ★ 批 7 T18 同步(登记制第二次使用):`46 → 47`(Σ 登记值 46 → 47,条目 28 → 29)——
246+
* 新增件 `components/NoteTagsEditor.tsx` 的 1 处标签编辑浮层(`level="raised"`)。legacy 9 行未动。
245247
*/
246-
export const FROZEN_SURFACE_TAG_TOTAL = 46;
248+
export const FROZEN_SURFACE_TAG_TOTAL = 47;
247249

248250
/**
249251
* 锚(三份;判据 ④ 逐条比对)—— `entries` = 基线条目数(防"表被悄悄删条目")、
@@ -272,7 +274,7 @@ export const SURFACE_TAG_FROZEN_LEGACY_COUNT = 14;
272274
* 登记制的锚(与三条棘轮的锚同形、独立;`entries` = 登记表条数 · `file/value` = 一个具名登记的
273275
* 冻结值)——防「登记表被静默清空 / 仪器把它读成 0 而总数判据仍绿」。**新登记 = 手工同步本锚。**
274276
*/
275-
export const SURFACE_TAG_ANCHOR = { entries: 28, file: "components/TaskConversationView.tsx", value: 3 } as const;
277+
export const SURFACE_TAG_ANCHOR = { entries: 29, file: "components/TaskConversationView.tsx", value: 3 } as const;
276278

277279
/**
278280
* ★ 阴影残留的分类与逐文件理由(`ShadowResidualKind` / `SHADOW_RESIDUAL`)**已移到

‎app/src/ui/primitives/surfaceResidual.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ export const SURFACE_TAG_REGISTRY: readonly SurfaceTagRegistryEntry[] = [
192192
{ file: "components/LiveImageStrip.tsx", count: 1, legacy: true, reason: "T17-B 第 2 批迁移:实时图像条的容器面 ⇒ 属迁移面" },
193193
{ file: "components/MaterialInputPanel.tsx", count: 1, reason: "学习素材卡容器(原 `const panel` 共享样式):批 7 T7 换成原语 `level=\"none\" radius=\"panel\" padded`" },
194194
{ file: "components/NotePreviewView.tsx", count: 2, reason: "AI 精修版 / 规则版预览的 markdown 阅读面(两处 `dangerouslySetInnerHTML`):批 7 T7 用受控槽 `html` 迁入" },
195+
{ file: "components/NoteTagsEditor.tsx", count: 1, reason: "标签编辑浮层(批 7 T18:`level=\"raised\"` 锚定面板,底/边/圆角/阴影四件都得走原语——该文件为新建件,无任何冻结键可承接字面量)" },
195196
{ file: "components/PhotoCapturePanel.tsx", count: 1, reason: "图文采集卡容器(原 `const panel` 共享样式):批 7 T7 换成原语 `level=\"none\" radius=\"panel\" padded`" },
196197
{ file: "components/PracticeQuestionsOverlays.tsx", count: 2, reason: "练习条目行容器(整圈边框 + 无底色,两处):批 7 T7 迁入 `level=\"none\"`,行内排布留在 `style`" },
197198
{ file: "components/ProfileDetector.tsx", count: 1, reason: "视频档案卡容器(整圈边框 + 无底色):批 7 T7 迁入 `level=\"none\" radius=\"panel\" padded`" },

0 commit comments

Comments
 (0)