Skip to content

Commit 1aee3fc

Browse files
committed
feat(classroom): 图文采集面板与全屏框选遮罩(截屏导入第三动线)
1 parent 270e082 commit 1aee3fc

5 files changed

Lines changed: 419 additions & 2 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* PhotoCapturePanel — 图文采集面板(v0.11.7 第三采集动线)。
3+
*
4+
* @ai-context: 三态动线(仿实时捕获):开始(创建 kind=photo 会话)→ 截屏
5+
* (全屏快照 → 框选遮罩 → canvas 裁剪 → 存图+OCR,即时反馈识别
6+
* 预览)→ 完成(零截图删会话不留空壳)/ 放弃。产物 = 图文会话,
7+
* 会话列表/详情/转笔记全链路复用现有会话体系。
8+
* @ai-context: 互斥(实时捕获进行中)由后端 start_photo_session 拒绝,前端
9+
* 仅原样展示错误原因(不静默)。
10+
*/
11+
import { useCallback, useState } from "react";
12+
import { invoke } from "@tauri-apps/api/core";
13+
import { convertFileSrc } from "@tauri-apps/api/core";
14+
import ScreenSelectOverlay from "./ScreenSelectOverlay";
15+
16+
const btn: React.CSSProperties = { padding: "6px 12px", cursor: "pointer", fontSize: 13 };
17+
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
18+
19+
interface Props {
20+
onOpenSessions?: (sessionId: number) => void;
21+
onStatus: (msg: string) => void;
22+
}
23+
24+
type Phase = "idle" | "collecting" | "done";
25+
26+
/** save_photo_capture 返回契约(Rust PhotoCaptureResult camelCase) */
27+
interface PhotoResult {
28+
duplicated: boolean;
29+
blockCount: number;
30+
preview: string[];
31+
imageRef: string;
32+
}
33+
34+
export default function PhotoCapturePanel({ onOpenSessions, onStatus }: Props) {
35+
const [phase, setPhase] = useState<Phase>("idle");
36+
const [sessionId, setSessionId] = useState<number | null>(null);
37+
const [count, setCount] = useState(0);
38+
const [lastImageRef, setLastImageRef] = useState<string | null>(null);
39+
const [baseUrl, setBaseUrl] = useState("");
40+
const [snapshot, setSnapshot] = useState<{ src: string; w: number; h: number } | null>(null);
41+
const [saving, setSaving] = useState(false);
42+
const [msg, setMsg] = useState("");
43+
44+
const start = async () => {
45+
try {
46+
// 标题时间由前端生成(JS Date;Rust 侧免引本地时间依赖)
47+
const now = new Date();
48+
const pad = (n: number) => String(n).padStart(2, "0");
49+
const title = `图文会话 ${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
50+
const id = await invoke<number>("start_photo_session", { title });
51+
setSessionId(id);
52+
setCount(0);
53+
setLastImageRef(null);
54+
setBaseUrl("");
55+
setMsg("");
56+
setPhase("collecting");
57+
onStatus(`图文采集已开始(会话 #${id})`);
58+
} catch (e) {
59+
onStatus(`开始采集失败: ${e}`);
60+
}
61+
};
62+
63+
const openCapture = async () => {
64+
try {
65+
const s = await invoke<{ base64: string; width: number; height: number }>("capture_screen_snapshot");
66+
setSnapshot({ src: `data:image/jpeg;base64,${s.base64}`, w: s.width, h: s.height });
67+
} catch (e) {
68+
onStatus(`截屏失败: ${e}`);
69+
}
70+
};
71+
72+
const confirmCapture = useCallback(
73+
async (pngBase64: string) => {
74+
if (sessionId == null) return;
75+
setSaving(true);
76+
try {
77+
const r = await invoke<PhotoResult>("save_photo_capture", { sessionId, imageB64: pngBase64 });
78+
setSnapshot(null);
79+
if (r.duplicated) {
80+
setMsg("⚠ 与上一张内容相同,未重复保存");
81+
return;
82+
}
83+
setCount((c) => c + 1);
84+
setLastImageRef(r.imageRef);
85+
if (baseUrl === "") {
86+
const url = await invoke<string>("session_images_base_url", { sessionId }).catch(() => "");
87+
setBaseUrl(url);
88+
}
89+
setMsg(
90+
r.blockCount > 0
91+
? `已识别 ${r.blockCount} 块:${r.preview.slice(0, 2).join(" · ")}`
92+
: "未识别到文字(截图已保存)",
93+
);
94+
} catch (e) {
95+
setMsg(`保存截图失败: ${e}`);
96+
} finally {
97+
setSaving(false);
98+
}
99+
},
100+
[sessionId, baseUrl],
101+
);
102+
103+
const finish = async () => {
104+
if (sessionId == null) return;
105+
try {
106+
const r = await invoke<string>("finish_photo_session", { sessionId });
107+
onStatus(r);
108+
setPhase("done");
109+
} catch (e) {
110+
onStatus(`完成采集失败: ${e}`);
111+
}
112+
};
113+
114+
const discard = async () => {
115+
if (sessionId == null) return;
116+
try {
117+
await invoke("discard_photo_session", { sessionId });
118+
onStatus("已放弃图文采集");
119+
setPhase("idle");
120+
setSessionId(null);
121+
} catch (e) {
122+
onStatus(`放弃失败: ${e}`);
123+
}
124+
};
125+
126+
return (
127+
<>
128+
<div style={panel}>
129+
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 6 }}>图文采集(截屏导入图文内容)</div>
130+
{phase === "idle" && (
131+
<button
132+
onClick={() => void start()}
133+
style={{ ...btn, width: "100%", padding: "8px 0", fontWeight: 600, background: "#0d9488", color: "#fff", border: "none", borderRadius: 6 }}
134+
>
135+
▶ 开始图文采集
136+
</button>
137+
)}
138+
{phase === "collecting" && sessionId != null && (
139+
<>
140+
<p style={{ fontSize: 11, color: "#0f766e", margin: "0 0 6px" }}>
141+
会话 #{sessionId} 采集中 · 已截 {count} 张
142+
</p>
143+
{lastImageRef && baseUrl && (
144+
<img
145+
src={convertFileSrc(`${baseUrl}/${lastImageRef}`)}
146+
alt="最近截图"
147+
style={{ width: "100%", maxHeight: 110, objectFit: "cover", borderRadius: 6, border: "1px solid #e5e7eb", marginBottom: 6, display: "block" }}
148+
/>
149+
)}
150+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
151+
<button style={{ ...btn, flex: 1, background: "#0d9488", color: "#fff", border: "none", borderRadius: 6 }} onClick={() => void openCapture()}>
152+
📷 截屏
153+
</button>
154+
<button style={{ ...btn, background: "#047857", color: "#fff", border: "none", borderRadius: 6 }} onClick={() => void finish()}>
155+
✅ 完成采集
156+
</button>
157+
<button style={{ ...btn, border: "1px solid #e5e7eb", borderRadius: 6 }} onClick={() => void discard()}>
158+
放弃
159+
</button>
160+
</div>
161+
{msg && <p style={{ fontSize: 11, color: "#374151", margin: "6px 0 0" }}>{msg}</p>}
162+
</>
163+
)}
164+
{phase === "done" && sessionId != null && (
165+
<>
166+
<p style={{ fontSize: 11, color: "#047857", margin: "0 0 6px" }}>✅ 图文会话 #{sessionId} 已保存({count} 张截图)</p>
167+
{onOpenSessions && (
168+
<button
169+
onClick={() => onOpenSessions(sessionId)}
170+
style={{ ...btn, width: "100%", background: "#fff", border: "1px solid #2563eb", color: "#2563eb", borderRadius: 6 }}
171+
>
172+
去会话页查看
173+
</button>
174+
)}
175+
</>
176+
)}
177+
</div>
178+
{snapshot && (
179+
<ScreenSelectOverlay
180+
src={snapshot.src}
181+
imageWidth={snapshot.w}
182+
imageHeight={snapshot.h}
183+
saving={saving}
184+
onConfirm={(b64) => void confirmCapture(b64)}
185+
onCancel={() => setSnapshot(null)}
186+
/>
187+
)}
188+
</>
189+
);
190+
}

0 commit comments

Comments
 (0)