Skip to content

Commit b78ddae

Browse files
committed
refactor(app): 行动中心独立为行动域页 + 笔记页/组侧栏拆件
1 parent 5a4a1f2 commit b78ddae

10 files changed

Lines changed: 340 additions & 149 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
1616
import ClassroomPage from "./pages/ClassroomPage";
1717
import NotesPage from "./pages/NotesPage";
1818
import SessionsPage from "./pages/SessionsPage";
19+
// v0.20.5:行动域页(做——行动中心独立成页,意图分层)
20+
import ActionPage from "./pages/ActionPage";
1921
// v0.16.0:AI 对话页(纯聊天 + AI 任务对话视图——DSH 交互范式)
2022
import ChatPage from "./pages/ChatPage";
2123
// 2026-08-21 用户需求:设置页(课堂助手设置类面板迁出,单页滚动+分组)
@@ -33,12 +35,14 @@ import CaptureOverlayPanel from "./components/CaptureOverlayPanel";
3335
import BrowserChrome from "./components/BrowserChrome";
3436
import type { AiTaskState } from "./types";
3537

36-
type Page = "classroom" | "sessions" | "notes" | "chat" | "knowledge" | "goals" | "settings";
38+
type Page = "classroom" | "sessions" | "notes" | "action" | "chat" | "knowledge" | "goals" | "settings";
3739

3840
const NAV_ITEMS: { key: Page; label: string }[] = [
3941
{ key: "classroom", label: "📡 课堂助手" },
4042
{ key: "sessions", label: "🗂 会话" },
4143
{ key: "notes", label: "📝 笔记" },
44+
// v0.20.5:行动域页(做——行动裁决/SOP/练习/问题;独立 Tab 唯一入口)
45+
{ key: "action", label: "✅ 行动" },
4246
// v0.16.0:AI 对话页(纯聊天 + AI 任务对话视图)
4347
{ key: "chat", label: "💬 AI 对话" },
4448
{ key: "knowledge", label: "🧠 体系" },
@@ -355,6 +359,10 @@ function App() {
355359
}}
356360
/>
357361
</div>
362+
{/* v0.20.5:行动域页(保活挂载 + active 门控切回重载——TD-004 模式) */}
363+
<div style={{ flex: 1, display: page === "action" ? "block" : "none", overflow: "hidden" }}>
364+
<ActionPage active={page === "action"} />
365+
</div>
358366
<div style={{ flex: 1, display: page === "chat" ? "block" : "none", overflow: "hidden" }}>
359367
{/* v0.16.0:AI 对话页——跨页跳转复用 focus 机制(任务对话引用 → 会话/笔记/设置) */}
360368
<ChatPage

‎app/src/components/GroupSidebar.test.tsx‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ beforeEach(() => {
3535
switch (cmd) {
3636
case "list_note_groups": return [groupA, groupB];
3737
case "count_due_cards": return 0;
38-
// v0.20.3(REQ-293):行动徽标(默认 0)
39-
case "action_badge_count": return 0;
4038
case "list_fragments": return [];
4139
case "get_feature_flags": return { feedCapture: true };
4240
case "week_contract_status":
@@ -74,7 +72,6 @@ function renderSidebar() {
7472
onGroupFilterChange={vi.fn()}
7573
onChanged={vi.fn()}
7674
onOpenReview={vi.fn()}
77-
onOpenAction={vi.fn()}
7875
selectedNoteId={null}
7976
onOpenInbox={vi.fn()}
8077
inboxActive={false}
@@ -93,7 +90,6 @@ describe("GroupSidebar ⓘ 弹层", () => {
9390
onGroupFilterChange={onGroupFilterChange}
9491
onChanged={vi.fn()}
9592
onOpenReview={vi.fn()}
96-
onOpenAction={vi.fn()}
9793
selectedNoteId={null}
9894
onOpenInbox={vi.fn()}
9995
inboxActive={false}
@@ -136,7 +132,6 @@ describe("GroupSidebar ⓘ 弹层", () => {
136132
onGroupFilterChange={onGroupFilterChange}
137133
onChanged={vi.fn()}
138134
onOpenReview={vi.fn()}
139-
onOpenAction={vi.fn()}
140135
selectedNoteId={null}
141136
onOpenInbox={vi.fn()}
142137
inboxActive={false}
@@ -241,7 +236,6 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => {
241236
onGroupFilterChange={vi.fn()}
242237
onChanged={onChanged}
243238
onOpenReview={vi.fn()}
244-
onOpenAction={vi.fn()}
245239
selectedNoteId={null}
246240
onOpenInbox={vi.fn()}
247241
inboxActive={false}
@@ -279,7 +273,6 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => {
279273
onGroupFilterChange={vi.fn()}
280274
onChanged={onChanged}
281275
onOpenReview={vi.fn()}
282-
onOpenAction={vi.fn()}
283276
selectedNoteId={null}
284277
onOpenInbox={vi.fn()}
285278
inboxActive={false}
@@ -306,7 +299,6 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => {
306299
onGroupFilterChange={onGroupFilterChange}
307300
onChanged={onChanged}
308301
onOpenReview={vi.fn()}
309-
onOpenAction={vi.fn()}
310302
selectedNoteId={null}
311303
onOpenInbox={vi.fn()}
312304
inboxActive={false}

‎app/src/components/GroupSidebar.tsx‎

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@ interface Props {
4040
onChanged: () => void;
4141
/** 打开复习面(groupId=null 全量;name 呈现用) */
4242
onOpenReview: (groupId: number | null, name: string) => void;
43-
/** v0.20.3(REQ-293):打开行动中心(✅ 徽标入口) */
44-
onOpenAction: () => void;
4543
/** 当前选中笔记 id(ⓘ 弹层"移入/移出选中笔记"用;null=无) */
4644
selectedNoteId: number | null;
4745
/** 打开收件箱视图(中部列表原位切换为碎片列表) */
@@ -57,16 +55,14 @@ interface Props {
5755
}
5856

5957
export default function GroupSidebar({
60-
width = 240, groupFilter, onGroupFilterChange, onChanged, onOpenReview, onOpenAction, selectedNoteId,
58+
width = 240, groupFilter, onGroupFilterChange, onChanged, onOpenReview, selectedNoteId,
6159
onOpenInbox, inboxActive, refreshToken, onOpenSystem, onCollapse,
6260
}: Props) {
6361
const [groups, setGroups] = useState<NoteGroup[]>([]);
6462
const [feedCaptureOn, setFeedCaptureOn] = useState(true);
6563
const [fragText, setFragText] = useState("");
6664
// 全量到期卡数("复习 N"徽标)
6765
const [dueTotal, setDueTotal] = useState(0);
68-
// v0.20.3(REQ-293):逾期行动数("✅ 行动 N"徽标)
69-
const [actionCount, setActionCount] = useState(0);
7066
// 收件箱待处理数(active 碎片计数)
7167
const [inboxCount, setInboxCount] = useState(0);
7268
const [status, setStatus] = useState("");
@@ -104,9 +100,6 @@ export default function GroupSidebar({
104100
setGroups(feedCaptureOn ? list : list.filter((g) => g.terrain !== "feed"));
105101
const due = await invoke<number>("count_due_cards", { groupId: null });
106102
setDueTotal(due);
107-
// v0.20.3(REQ-293):逾期行动徽标(裁决队列数据源)
108-
const actions = await invoke<number>("action_badge_count");
109-
setActionCount(actions);
110103
const frags = await invoke<Fragment[]>("list_fragments", { status: "active", limit: 500 });
111104
setInboxCount(frags.length);
112105
// v0.13.7 触点①:体系 + 引用拉取(并行——徽标数据与组列表无依赖)
@@ -418,23 +411,16 @@ export default function GroupSidebar({
418411
</p>
419412
)}
420413

421-
{/* 🎴 复习(全量入口:UI 最小化——一个按钮 + 到期数) */}
422-
<div style={{ marginTop: "auto", paddingTop: 6, display: "flex", flexDirection: "column", gap: 4 }}>
414+
{/* 🎴 复习(全量入口:UI 最小化——一个按钮 + 到期数;v0.20.5:✅ 行动
415+
入口与徽标已移除——行动独立为顶层「✅ 行动」Tab 唯一入口) */}
416+
<div style={{ marginTop: "auto", paddingTop: 6 }}>
423417
<button
424418
onClick={() => onOpenReview(null, "全部组")}
425419
style={{ width: "100%", fontSize: 11, cursor: "pointer", padding: "4px 8px", borderRadius: 4, border: "1px solid #0f766e", background: dueTotal > 0 ? "#f0fdfa" : "#fff", color: "#0f766e" }}
426420
title="开始复习到期卡片"
427421
>
428422
🎴 复习{dueTotal > 0 ? ` ${dueTotal}` : ""}
429423
</button>
430-
{/* v0.20.3(REQ-293):✅ 行动(逾期裁决入口——组侧栏全量入口先例同款) */}
431-
<button
432-
onClick={onOpenAction}
433-
style={{ width: "100%", fontSize: 11, cursor: "pointer", padding: "4px 8px", borderRadius: 4, border: "1px solid #7c3aed", background: actionCount > 0 ? "#f5f3ff" : "#fff", color: "#6d28d9" }}
434-
title="行动中心——逾期裁决/完成史"
435-
>
436-
✅ 行动{actionCount > 0 ? ` ${actionCount}` : ""}
437-
</button>
438424
</div>
439425
</div>
440426

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* NoteHeaderActions — 笔记阅读头动作组(v0.20.5:NotesPage 编排瘦身拆分)。
3+
*
4+
* @ai-context: 原内联于 NotesPage 阅读视图 headerExtra 插槽——色点(笔记级
5+
* 颜色入口:显式色写库、显示色含组继承)/ 移动到组 / 挂体系 /
6+
* 🤖 AI 精修入口 / 🧠 模型卡草稿入口。本组件只消费选中笔记与
7+
* 回调,自持局部态(色板开合 + 主题推导),NotesPage 不必感知;
8+
* 错误经 onError 上抛(沿用父层 status 区展示,不吞异常)。
9+
* @ai-context: 全部为「对单条笔记的转换/组织动作」——v0.20.5 域页剥离时
10+
* 裁决保留在笔记上下文(不属行动/复习域)。
11+
*/
12+
import { useMemo, useState } from "react";
13+
import { invoke } from "@tauri-apps/api/core";
14+
import type { Note, NoteGroup } from "../types";
15+
import { paletteHex, parseNoteProperties } from "../utils/colorPalette";
16+
import type { ThemeMode } from "../utils/colorPalette";
17+
import NoteColorPicker from "./NoteColorPicker";
18+
import NoteMoveToGroupMenu from "./NoteMoveToGroupMenu";
19+
import NoteLinkToSystem from "./NoteLinkToSystem";
20+
21+
interface Props {
22+
note: Note;
23+
/** 已解析显示色(含组继承——父层 noteColors 映射给出;null=默认灰) */
24+
resolvedColor: string | null;
25+
groups: NoteGroup[];
26+
/** 变更后父层刷新(列表重载 + 右栏选中对象回读) */
27+
onChanged: () => void;
28+
/** 错误上抛(父层 status 区展示) */
29+
onError: (msg: string) => void;
30+
/** 跳体系页并打开建体系向导(TD-2026-09-05-A 空体系引导) */
31+
onGotoKnowledgeSystem?: () => void;
32+
/** 打开编辑态 AI 能力对话框(阅读态点击直接进入编辑态) */
33+
onOpenAi: () => void;
34+
/** 打开模型卡草稿对话框(组内 model 卡唯一生成链) */
35+
onOpenModelCard: () => void;
36+
}
37+
38+
export default function NoteHeaderActions({
39+
note, resolvedColor, groups, onChanged, onError, onGotoKnowledgeSystem, onOpenAi, onOpenModelCard,
40+
}: Props) {
41+
const [colorPickerOpen, setColorPickerOpen] = useState(false);
42+
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
43+
const theme: ThemeMode = useMemo(
44+
() => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
45+
[],
46+
);
47+
48+
return (
49+
<>
50+
{/* v0.14 B:笔记级颜色入口——色点显示解析色(含继承),picker 编辑显式色 */}
51+
<div style={{ position: "relative" }}>
52+
<span
53+
data-testid="note-color-dot"
54+
onClick={() => setColorPickerOpen((v) => !v)}
55+
title="设置笔记颜色"
56+
style={{ width: 12, height: 12, borderRadius: 3, cursor: "pointer", display: "inline-block", background: paletteHex(resolvedColor, theme) }}
57+
/>
58+
{colorPickerOpen && (
59+
<div
60+
data-testid="note-color-picker-pop"
61+
style={{ position: "absolute", top: "100%", right: 0, zIndex: 30, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 6, padding: 8, boxShadow: "0 4px 12px rgba(0,0,0,0.12)" }}
62+
>
63+
<NoteColorPicker
64+
value={parseNoteProperties(note).color ?? null}
65+
onChange={(color) => {
66+
invoke("update_note_color", { id: note.id, color })
67+
.then(() => {
68+
setColorPickerOpen(false);
69+
// 刷新列表(色条)+ 右栏(笔记对象,handleNoteChanged 内 get_note 取最新)
70+
onChanged();
71+
})
72+
.catch((e) => onError(`笔记颜色设置失败: ${e}`));
73+
}}
74+
/>
75+
</div>
76+
)}
77+
</div>
78+
{/* v0.16.1:手动分组显式入口——移动/移出组(onChanged 刷新列表+右栏) */}
79+
<NoteMoveToGroupMenu
80+
key={`move-${note.id}`}
81+
note={note}
82+
groups={groups}
83+
onChanged={onChanged}
84+
/>
85+
{/* v0.13.7 触点②:标题栏「挂到体系」入口;key=note.id 切笔记重置内部态 */}
86+
<NoteLinkToSystem
87+
key={`link-${note.id}`}
88+
noteId={note.id}
89+
onChanged={onChanged}
90+
onGotoKnowledgeSystem={onGotoKnowledgeSystem}
91+
/>
92+
{/* v0.17.0:编辑态 AI 能力统一入口(阅读态点击直接进入编辑态) */}
93+
<button
94+
data-testid="note-ai-entry"
95+
onClick={onOpenAi}
96+
title="AI 精修 / 知识补充"
97+
style={{
98+
padding: "3px 8px", cursor: "pointer", fontSize: 11, borderRadius: 6,
99+
border: "1px solid #c7d2fe", background: "#f5f3ff", color: "#4c1d95",
100+
}}
101+
>
102+
🤖 AI
103+
</button>
104+
{/* v0.20.3(REQ-302):笔记段 → 模型卡草稿(组内 model 卡唯一生成链) */}
105+
<button
106+
data-testid="note-model-card-entry"
107+
onClick={onOpenModelCard}
108+
title="把本条笔记提炼为模型卡草稿(需已归组)"
109+
style={{
110+
padding: "3px 8px", cursor: "pointer", fontSize: 11, borderRadius: 6,
111+
border: "1px solid #d1fae5", background: "#ecfdf5", color: "#047857",
112+
}}
113+
>
114+
🧠 模型卡
115+
</button>
116+
</>
117+
);
118+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* ActionCenterPanel.test.tsx — 行动域页面板关键路径(v0.20.5 独立页化)。
4+
*
5+
* @ai-context: 覆盖页面化改造后的骨架行为——挂载全量拉取(四分区/完成史/
6+
* SOP 库)、三页签切换、refreshToken 递增触发全量重载(ActionPage
7+
* active 门控切回补偿)。invoke 全 mock(零后端依赖)。
8+
*/
9+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
11+
12+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
13+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
14+
15+
import ActionCenterPanel from "./ActionCenterPanel";
16+
17+
const queueCalls = () => invokeMock.mock.calls.filter((c) => c[0] === "list_action_queue").length;
18+
19+
beforeEach(() => {
20+
invokeMock.mockReset();
21+
invokeMock.mockImplementation(async (cmd: string) => {
22+
switch (cmd) {
23+
case "list_action_queue": return [];
24+
case "completion_history_list": return [];
25+
case "sop_template_list": return [];
26+
case "list_notes": return [];
27+
default:
28+
throw new Error(`unexpected: ${cmd}`);
29+
}
30+
});
31+
});
32+
33+
afterEach(() => cleanup());
34+
35+
describe("ActionCenterPanel 页面化骨架", () => {
36+
it("挂载即全量拉取(四分区/完成史/SOP 库/笔记列表)", async () => {
37+
render(<ActionCenterPanel />);
38+
await waitFor(() => {
39+
expect(invokeMock).toHaveBeenCalledWith("list_action_queue", { tab: "overdue", noteId: null });
40+
expect(invokeMock).toHaveBeenCalledWith("list_action_queue", { tab: "planned", noteId: null });
41+
expect(invokeMock).toHaveBeenCalledWith("list_action_queue", { tab: "someday", noteId: null });
42+
expect(invokeMock).toHaveBeenCalledWith("list_action_queue", { tab: "unrefined", noteId: null });
43+
expect(invokeMock).toHaveBeenCalledWith("completion_history_list", { eventType: null, limit: 150 });
44+
expect(invokeMock).toHaveBeenCalledWith("sop_template_list", { noteId: null });
45+
expect(invokeMock).toHaveBeenCalledWith("list_notes");
46+
});
47+
});
48+
49+
it("三页签切换:队列空态 / 完成史空态 / SOP 库空态", async () => {
50+
render(<ActionCenterPanel />);
51+
expect(await screen.findByText(/无逾期——裁决是机制不是自动清理/)).toBeTruthy();
52+
fireEvent.click(screen.getByRole("button", { name: /完成史/ }));
53+
expect(await screen.findByText(/暂无完成记录/)).toBeTruthy();
54+
fireEvent.click(screen.getByRole("button", { name: /SOP 库/ }));
55+
expect(await screen.findByText(/暂无 SOP 模板/)).toBeTruthy();
56+
// 回到队列页签不抛错(三区共享状态)
57+
fireEvent.click(screen.getByRole("button", { name: /裁决队列/ }));
58+
expect(screen.getByText(/无逾期——裁决是机制不是自动清理/)).toBeTruthy();
59+
});
60+
61+
it("refreshToken 递增触发全量重载(切回补偿)", async () => {
62+
const { rerender } = render(<ActionCenterPanel refreshToken={0} />);
63+
await waitFor(() => expect(queueCalls()).toBeGreaterThanOrEqual(4));
64+
const before = invokeMock.mock.calls.length;
65+
rerender(<ActionCenterPanel refreshToken={1} />);
66+
await waitFor(() => expect(invokeMock.mock.calls.length).toBeGreaterThan(before));
67+
expect(queueCalls()).toBeGreaterThanOrEqual(4);
68+
});
69+
70+
it("页面形态无遮罩关闭语义:渲染为页内容器(无 overlay 层级依赖)", () => {
71+
const { container } = render(<ActionCenterPanel />);
72+
// 原 Overlay 为 position:fixed 全屏遮罩——页面化后根容器为普通文档流占位
73+
expect(container.firstElementChild?.getAttribute("style") ?? "").not.toContain("position: fixed");
74+
expect(screen.queryByText("关闭")).toBeNull();
75+
});
76+
});

0 commit comments

Comments
 (0)