Skip to content

Commit 211a1ca

Browse files
committed
refactor(ui): 拆 ClassroomPage 第 4-6 步 — 左栏/窗口/浮窗
1 parent 25cd23d commit 211a1ca

7 files changed

Lines changed: 382 additions & 188 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* ClassroomSourceColumn — 课堂助手左栏配置列(批 0-C2 Task 4 步 4 自
3+
* ClassroomPage.tsx 抽出,纯搬运、行为等价)。
4+
*
5+
* @ai-context: 结构 = 折叠态单元素(ColumnBar 窄条)或展开态外壳 div(标题「📡 课堂助手」+
6+
* 健康徽标 + 折叠钮 → 横幅插槽 → 滚动容器:就绪清单 / 系统窗口开关 /
7+
* 窗口选择卡 / 捕获卡插槽 / 四条采集动线面板 / 状态行)。横幅与捕获卡由
8+
* 页面以 ReactNode 注入(banners / capturePanel)——本文件不订阅任何事件、
9+
* 不持有状态,全部输入经 props。
10+
* @ai-context: ★ 顶层恰好返回**一个**元素(折叠态 ColumnBar,否则外壳 div)——根 flex
11+
* 只有三个直接子元素(本列 + ColumnResizer + 右栏),多包一层或返回
12+
* Fragment 会改变 flex 分配与 borderRight/宽度语义,**且无编译错误**。
13+
* 同理 `<ColumnResizer>` 是本列的**兄弟**、由页面在折叠三元之外无条件渲染,
14+
* 不得搬进本文件(否则折叠态拖拽手柄消失)。
15+
* @ai-context: 耦合逐字保留 —— 外壳内联样式键(width / flexShrink / borderRight /
16+
* display / flexDirection / minWidth)、`title="折叠侧栏"`、折叠钮 `⟨`、
17+
* 文案「显示系统窗口(终端/资源管理器等)」、色值 #e5e7eb/#9ca3af/#6b7280、
18+
* 间距 padding 10px 14px / gap 12 / padding 12 均为现状契约;本文件零
19+
* className / 零 id / 零 data-testid,不 token 化、不换 emoji。
20+
* @ai-context: 边界 —— 就绪清单(ReadyCheckCard)与页内模型卡的矛盾(G6)、采集中仍可
21+
* 改选窗口(G7)是既有缺陷,本批只搬不改。
22+
*/
23+
import { WindowSelectCard } from "./WindowSelectCard";
24+
import VideoImportPanel from "./VideoImportPanel";
25+
import ReadyCheckCard from "./ReadyCheckCard";
26+
import { SystemStatusBadge } from "./SystemStatusBadge";
27+
import ColumnBar from "./ColumnBar";
28+
import MaterialInputPanel from "./MaterialInputPanel";
29+
import PhotoCapturePanel from "./PhotoCapturePanel";
30+
import WebImportPanel from "./WebImportPanel";
31+
import type { ReactNode } from "react";
32+
import type { Note, WindowInfo } from "../types";
33+
34+
interface Props {
35+
/** 生效折叠态(useColumnLayout.folded:自动折叠或手动折叠任一成立) */
36+
folded: boolean;
37+
/** 生效列宽(useColumnLayout.width,已夹取 min..max) */
38+
width: number;
39+
/** 点窄条展开(useColumnLayout.expand:同时清自动/手动折叠态) */
40+
onExpand: () => void;
41+
/** 标题栏折叠钮(useColumnLayout.setManualFolded(true)) */
42+
onFold: () => void;
43+
/** 页面级提示横幅插槽(ClassroomBanners——须保持为左栏 div 的直接子元素) */
44+
banners: ReactNode;
45+
/** 实时捕获卡插槽(ClassroomCapturePanel) */
46+
capturePanel: ReactNode;
47+
/** 窗口枚举结果(系统窗口开关的可显性判据 + 列表数据源) */
48+
windows: WindowInfo[];
49+
selectedWindow: WindowInfo | null;
50+
onSelectWindow: (win: WindowInfo) => void;
51+
/** 重新枚举窗口(background=true 时不显示 loading——列表内刷新用) */
52+
onRefreshWindows: (background?: boolean) => void;
53+
windowsLoading: boolean;
54+
/** 显示系统窗口(终端/资源管理器等)开关 */
55+
showSystemWindows: boolean;
56+
onShowSystemWindows: (show: boolean) => void;
57+
onOpenSessions?: (sessionId: number) => void;
58+
/** MaterialInputPanel 产物回填(页面 lastNote) */
59+
onNote: (note: Note) => void;
60+
/** 状态行(页面 status 单一状态源) */
61+
status: string;
62+
onStatus: (message: string) => void;
63+
}
64+
65+
export default function ClassroomSourceColumn({
66+
folded,
67+
width,
68+
onExpand,
69+
onFold,
70+
banners,
71+
capturePanel,
72+
windows,
73+
selectedWindow,
74+
onSelectWindow,
75+
onRefreshWindows,
76+
windowsLoading,
77+
showSystemWindows,
78+
onShowSystemWindows,
79+
onOpenSessions,
80+
onNote,
81+
status,
82+
onStatus,
83+
}: Props) {
84+
return folded ? (
85+
<ColumnBar icon="📡" title="课堂助手" onClick={onExpand} />
86+
) : (
87+
<div
88+
style={{
89+
width,
90+
flexShrink: 0,
91+
borderRight: "1px solid #e5e7eb",
92+
display: "flex",
93+
flexDirection: "column",
94+
minWidth: 0,
95+
}}
96+
>
97+
<div style={{ padding: "10px 14px", borderBottom: "1px solid #e5e7eb", fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
98+
<span>📡 课堂助手</span>
99+
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
100+
{/* M7/REQ-042 F2/G2:健康徽标 + 诊断面板(开发期可见) */}
101+
<SystemStatusBadge />
102+
<button onClick={() => onFold()} style={{ fontSize: 12, cursor: "pointer", border: "none", background: "none", color: "#9ca3af" }} title="折叠侧栏">⟨</button>
103+
</div>
104+
</div>
105+
106+
{banners}
107+
108+
<div style={{ flex: 1, minHeight: 0, padding: 12, overflowY: "auto", display: "flex", flexDirection: "column", gap: 12 }}>
109+
{/* 2026-08 C1:引擎与模型就绪清单(开始前准备流——缺什么一目了然) */}
110+
<ReadyCheckCard />
111+
112+
{/* 目标窗口/进程选择(v0.2.0 实时捕获上下文) */}
113+
{/* v0.19.2(用户实测):系统窗口默认过滤,开关找回(能力不丢) */}
114+
{windows.some((w) => w.systemWindow) && (
115+
<label style={{ fontSize: 11, color: "#6b7280", display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }}>
116+
<input
117+
type="checkbox"
118+
checked={showSystemWindows}
119+
onChange={(e) => onShowSystemWindows(e.target.checked)}
120+
/>
121+
显示系统窗口(终端/资源管理器等)
122+
</label>
123+
)}
124+
<WindowSelectCard
125+
windows={showSystemWindows ? windows : windows.filter((w) => !w.systemWindow)}
126+
selected={selectedWindow}
127+
onSelect={onSelectWindow}
128+
onRefresh={onRefreshWindows}
129+
loading={windowsLoading}
130+
/>
131+
132+
{capturePanel}
133+
134+
{/* 视频文件导入(v0.3.0:REQ-015 第二入口,字幕优先 + ASR fallback) */}
135+
<VideoImportPanel onOpenSessions={onOpenSessions} />
136+
137+
{/* 2026-08-21 用户需求:OCR 设备/音频预处理/备份/AI 服务/词表/模型管理等
138+
设置类面板已迁出至「⚙ 设置」页——课堂助手左栏仅保留采集动线 */}
139+
140+
{/* 素材输入 + 提取按钮(v0.1.0 文件流水线;审查硬拆——MaterialInputPanel) */}
141+
<MaterialInputPanel
142+
windowTitle={selectedWindow?.title ?? null}
143+
onNote={onNote}
144+
onStatus={onStatus}
145+
/>
146+
147+
{/* v0.11.7:图文采集(第三动线:截屏导入图文内容 → 图文会话) */}
148+
<PhotoCapturePanel onOpenSessions={onOpenSessions} onStatus={onStatus} />
149+
150+
{/* v0.20.4(REQ-303):web 采集(第四条动线:URL 静态直取 → kind=web 会话) */}
151+
<WebImportPanel onOpenSessions={onOpenSessions} onStatus={onStatus} />
152+
153+
{status && <p style={{ fontSize: 12, color: "#2563eb" }}>{status}</p>}
154+
</div>
155+
</div>
156+
);
157+
}

‎app/src/hooks/useClassroomFloat.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* useClassroomFloat — 课堂助手采集浮窗状态与浮窗快捷键(批 0-C2 Task 4 步 6 自
3+
* ClassroomPage.tsx 抽出,纯搬运、行为等价)。
4+
*
5+
* @ai-context: 浮窗三态(浮窗化 ⇄ 收起 ⇄ 解锁点击穿透)的唯一真源是 Rust
6+
* (ADR-025 float_toggle_core)——本 hook 只做三件事:①挂载拉取
7+
* `float_state`;②订阅 `float:state` 事件回流;③按钮与快捷键统一调
8+
* `float_toggle`,前端不自行推导状态。
9+
* @ai-context: ★ R4 —— Ctrl+Shift+F 是**两个** window keydown 中的第二个(第一个
10+
* Ctrl+Shift+S 在 useClassroomShortcuts),deps 必须逐字保持
11+
* `[active, floatSnap.open, toggleFloat]` 且保留 `active && !floatSnap.open`
12+
* 守卫:浮窗打开期间该键已升级为 Rust 全局快捷键,主窗必须让位,
13+
* 否则一次按键双触发双翻转。preventDefault 位置与成对 cleanup 不变。
14+
* @ai-context: 副作用与边界 —— invoke `float_state` / `float_toggle`;挂载拉取带
15+
* `disposed` 守卫(卸载后不 setState);切换失败**不吞**,经
16+
* `onLiveError` 写页面错误横幅(「浮窗切换失败: …」逐字保留)。
17+
*/
18+
import { useCallback, useEffect, useState } from "react";
19+
import { invoke } from "@tauri-apps/api/core";
20+
import { listen } from "@tauri-apps/api/event";
21+
import type { FloatSnapshot } from "./useFloatWindow";
22+
23+
interface Options {
24+
/** 采集中(未采集时 Ctrl+Shift+F 不生效——与拆分前一致) */
25+
active: boolean;
26+
/** 错误横幅写入(页面 liveError 单一状态源) */
27+
onLiveError: (message: string) => void;
28+
}
29+
30+
export function useClassroomFloat({ active, onLiveError }: Options) {
31+
// v0.12.3:浮窗状态(按钮语义:浮窗化 ⇄ 收起 ⇄ 解锁穿透;Rust 单一来源)
32+
const [floatSnap, setFloatSnap] = useState<FloatSnapshot>({ open: false, locked: false, topmost: true });
33+
34+
// v0.12.0 M6:浮窗化快捷键 Ctrl+Shift+F(采集中一键浮窗——全屏看视频不中断)
35+
// v0.12.6(ADR-025):三态语义收拢到 Rust float_toggle(单一来源,防主窗键与
36+
// 全局快捷键双触发双翻转)——前端按钮/键只调 toggle,状态由 float:state 事件回流
37+
const toggleFloat = useCallback(() => {
38+
void invoke<FloatSnapshot>("float_toggle")
39+
.then(setFloatSnap)
40+
.catch((err) => onLiveError(`浮窗切换失败: ${err}`));
41+
// eslint-disable-next-line react-hooks/exhaustive-deps
42+
}, []);
43+
44+
// v0.12.3:浮窗状态同步(挂载拉取 + float:state 事件订阅——Rust 单一来源)
45+
useEffect(() => {
46+
let disposed = false;
47+
const unlisteners: Promise<() => void>[] = [];
48+
void invoke<FloatSnapshot>("float_state")
49+
.then((s) => {
50+
if (!disposed) setFloatSnap(s);
51+
})
52+
.catch(() => undefined);
53+
unlisteners.push(
54+
listen<FloatSnapshot>("float:state", (e) => {
55+
if (!disposed) setFloatSnap(e.payload);
56+
}),
57+
);
58+
return () => {
59+
disposed = true;
60+
unlisteners.forEach((p) => void p.then((fn) => fn()));
61+
};
62+
}, []);
63+
64+
useEffect(() => {
65+
const onKey = (e: KeyboardEvent) => {
66+
// v0.12.6:仅浮窗关闭时生效——浮窗打开期间快捷键已升级为全局快捷键
67+
// (Rust 侧统一处理,语义见 float_toggle_core),此处拦截避免双触发
68+
if (active && !floatSnap.open && e.ctrlKey && e.shiftKey && (e.key === "F" || e.key === "f")) {
69+
e.preventDefault();
70+
toggleFloat();
71+
}
72+
};
73+
window.addEventListener("keydown", onKey);
74+
return () => window.removeEventListener("keydown", onKey);
75+
}, [active, floatSnap.open, toggleFloat]);
76+
77+
return { floatSnap, toggleFloat };
78+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* useClassroomShortcuts — 课堂助手页面级快捷键(批 0-C2 Task 4 步 6 自
3+
* ClassroomPage.tsx 抽出,纯搬运、行为等价)。
4+
*
5+
* @ai-context: v0.5.0 M6(REQ-051)用户截图快捷键 Ctrl+Shift+S——最高权重关键图
6+
* 信号:采集中按下即 `save_user_screenshot`,成功写状态行、失败写错误
7+
* 横幅(文案「📷 截图已保存(关键图候选置顶)」/「截图失败: …」逐字保留)。
8+
* @ai-context: ★ R4 —— 本监听是**两个** window keydown 中的第一个,deps 必须保持
9+
* `[]`(拆分前即 `[]`;onStatus/onLiveError 都是稳定 setter,不入 deps
10+
* 不改变重订阅时机)。`preventDefault()` 在 invoke 之前、成对 cleanup
11+
* 不变。**不得**与 useClassroomFloat 的 Ctrl+Shift+F 合并成一个监听
12+
* (浮窗打开时主窗键必须让位 Rust 全局键,合并即行为变更)。
13+
* @ai-context: 已知缺陷 B1(只搬不改):正文 Ctrl+Shift+S 与 NoteEditView 拆段同键
14+
* 双义(textarea 未 stopPropagation),本批行为等价、原样保留。
15+
*/
16+
import { useEffect } from "react";
17+
import { invoke } from "@tauri-apps/api/core";
18+
19+
interface Options {
20+
/** 状态行写入(页面 status 单一状态源) */
21+
onStatus: (message: string) => void;
22+
/** 错误横幅写入(页面 liveError 单一状态源) */
23+
onLiveError: (message: string) => void;
24+
}
25+
26+
export function useClassroomShortcuts({ onStatus, onLiveError }: Options) {
27+
// v0.5.0 M6(REQ-051):用户截图快捷键 Ctrl+Shift+S(最高权重关键图信号)
28+
useEffect(() => {
29+
const onKey = (e: KeyboardEvent) => {
30+
if (e.ctrlKey && e.shiftKey && (e.key === "S" || e.key === "s")) {
31+
e.preventDefault();
32+
void invoke<string>("save_user_screenshot")
33+
.then(() => onStatus("📷 截图已保存(关键图候选置顶)"))
34+
.catch((err) => onLiveError(`截图失败: ${err}`));
35+
}
36+
};
37+
window.addEventListener("keydown", onKey);
38+
return () => window.removeEventListener("keydown", onKey);
39+
// eslint-disable-next-line react-hooks/exhaustive-deps
40+
}, []);
41+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* useClassroomWindows — 课堂助手目标窗口/进程枚举与选中态(批 0-C2 Task 4 步 5 自
3+
* ClassroomPage.tsx 抽出,纯搬运、行为等价)。
4+
*
5+
* @ai-context: 职责 = `list_windows` 枚举 + 选中窗口:首挂载自动枚举一次,供左栏
6+
* 窗口选择卡(含"系统窗口"过滤开关)与右栏档案检测消费;刷新入参
7+
* `background=true` 时不动 loading(列表内手动刷新不闪加载态)。
8+
* @ai-context: 副作用与边界 —— 挂载时一次 invoke;枚举失败**不吞**,以
9+
* 「窗口枚举失败: …」写回页面状态行(`onStatus`:页面 status 单一
10+
* 状态源,D5)。本 hook 不订阅事件、不持有列宽/折叠(属 useColumnLayout),
11+
* 也不管采集目标锁(G7「采集中仍可改选窗口」是既有缺陷,只搬不改)。
12+
*/
13+
import { useCallback, useEffect, useState } from "react";
14+
import { invoke } from "@tauri-apps/api/core";
15+
import type { WindowInfo } from "../types";
16+
17+
export function useClassroomWindows(onStatus: (message: string) => void) {
18+
// ── 窗口/进程选择 ──
19+
const [windows, setWindows] = useState<WindowInfo[]>([]);
20+
const [selectedWindow, setSelectedWindow] = useState<WindowInfo | null>(null);
21+
const [windowsLoading, setWindowsLoading] = useState(false);
22+
23+
const refreshWindows = useCallback(async (background = false) => {
24+
if (!background) setWindowsLoading(true);
25+
try {
26+
const list = await invoke<WindowInfo[]>("list_windows");
27+
setWindows(list);
28+
} catch (e) {
29+
onStatus(`窗口枚举失败: ${e}`);
30+
} finally {
31+
if (!background) setWindowsLoading(false);
32+
}
33+
// eslint-disable-next-line react-hooks/exhaustive-deps
34+
}, []);
35+
36+
// 首次进入自动枚举一次窗口
37+
useEffect(() => {
38+
void refreshWindows();
39+
}, [refreshWindows]);
40+
41+
return { windows, selectedWindow, setSelectedWindow, windowsLoading, refreshWindows };
42+
}

0 commit comments

Comments
 (0)