Skip to content

Commit 5147d85

Browse files
committed
feat(ocr): 结构图前端——屏卡框选/图库区段/大图预览+全选框+TD-E接入
1 parent b13ead0 commit 5147d85

11 files changed

Lines changed: 702 additions & 21 deletions

‎app/src-tauri/src/db_structures.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ pub fn delete_structure_image(db: &Db, id: i64) -> Result<Option<StructureImageR
9898
row_to_record,
9999
)
100100
.optional()?;
101-
if let Some(rec) = &existing {
101+
if existing.is_some() {
102102
conn.execute("DELETE FROM session_structure_images WHERE id = ?1", params![id])?;
103103
}
104104
Ok(existing)

‎app/src-tauri/src/structure_capture.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ fn frame_candidates(
138138
// 均匀抽样(首尾必含——首帧=屏开始画面、尾帧=最近画面,都值得参与选优)
139139
let last = in_window.len() - 1;
140140
(0..max)
141-
.map(|i| in_window[((i as f32 * last as f32 / (max - 1) as f32).round() as usize)])
141+
.map(|i| in_window[(i as f32 * last as f32 / (max - 1) as f32).round() as usize])
142142
.collect()
143143
}
144144

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* BoxSelectOverlay — 屏卡全帧图框选截取(v0.7.7 REQ-184)。
3+
*
4+
* @ai-context: 在屏卡图上叠加选择层:鼠标拖框(归一化坐标 0-1,与显示/原图
5+
* 线性一致)→ 松开出确认浮层(CSS 放大镜预览裁剪区域 + 保存/取消,
6+
* 防误触产生垃圾图)→ 保存 invoke capture_structure_manual。
7+
* 无图屏不进入(调用方禁用按钮)。
8+
*/
9+
import { useRef, useState } from "react";
10+
import { invoke } from "@tauri-apps/api/core";
11+
12+
interface Props {
13+
src: string;
14+
sessionId: number;
15+
screenId: number | null;
16+
/** 保存成功回调(父层 toast + 图库自动刷新走事件) */
17+
onDone: () => void;
18+
onCancel: () => void;
19+
}
20+
21+
interface Box {
22+
x: number;
23+
y: number;
24+
w: number;
25+
h: number;
26+
}
27+
28+
export default function BoxSelectOverlay({ src, sessionId, screenId, onDone, onCancel }: Props) {
29+
const wrapRef = useRef<HTMLDivElement>(null);
30+
const [dragging, setDragging] = useState(false);
31+
const [box, setBox] = useState<Box | null>(null);
32+
const [confirm, setConfirm] = useState<Box | null>(null);
33+
const [saving, setSaving] = useState(false);
34+
const [error, setError] = useState("");
35+
36+
const norm = (clientX: number, clientY: number): Box | null => {
37+
const el = wrapRef.current;
38+
if (!el) return null;
39+
const rect = el.getBoundingClientRect();
40+
return {
41+
x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
42+
y: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)),
43+
w: 1,
44+
h: 1,
45+
};
46+
};
47+
48+
const onMouseDown = (e: React.MouseEvent) => {
49+
const p = norm(e.clientX, e.clientY);
50+
if (!p) return;
51+
setBox(p);
52+
setDragging(true);
53+
setConfirm(null);
54+
setError("");
55+
};
56+
57+
const onMouseMove = (e: React.MouseEvent) => {
58+
if (!dragging || !box) return;
59+
const p = norm(e.clientX, e.clientY);
60+
if (!p) return;
61+
setBox({
62+
x: Math.min(box.x, p.x),
63+
y: Math.min(box.y, p.y),
64+
w: Math.abs(p.x - box.x),
65+
h: Math.abs(p.y - box.y),
66+
});
67+
};
68+
69+
const onMouseUp = () => {
70+
if (!dragging || !box) return;
71+
setDragging(false);
72+
// 过小框(<6% 宽高)视为误触:忽略本次拖拽
73+
if (box.w < 0.06 || box.h < 0.06) {
74+
setBox(null);
75+
return;
76+
}
77+
setConfirm(box);
78+
};
79+
80+
const save = async () => {
81+
if (!confirm) return;
82+
setSaving(true);
83+
setError("");
84+
try {
85+
await invoke("capture_structure_manual", {
86+
sessionId,
87+
screenId,
88+
x: confirm.x,
89+
y: confirm.y,
90+
w: confirm.w,
91+
h: confirm.h,
92+
});
93+
onDone();
94+
} catch (e) {
95+
setError(`保存失败: ${e}`);
96+
} finally {
97+
setSaving(false);
98+
}
99+
};
100+
101+
const boxStyle: React.CSSProperties = box
102+
? {
103+
position: "absolute",
104+
left: `${box.x * 100}%`,
105+
top: `${box.y * 100}%`,
106+
width: `${box.w * 100}%`,
107+
height: `${box.h * 100}%`,
108+
border: "2px solid #0d9488",
109+
background: "rgba(13,148,136,0.12)",
110+
pointerEvents: "none",
111+
}
112+
: {};
113+
114+
// CSS 放大镜预览:以框选区域为视口显示原图(确认浮层)
115+
const zoomStyle: React.CSSProperties = confirm
116+
? {
117+
width: 240,
118+
height: 160,
119+
backgroundImage: `url(${src})`,
120+
backgroundSize: `${100 / confirm.w}% ${100 / confirm.h}%`,
121+
backgroundPosition: `${(confirm.x / (1 - confirm.w)) * 100}% ${(confirm.y / (1 - confirm.h)) * 100}%`,
122+
backgroundRepeat: "no-repeat",
123+
border: "1px solid #e5e7eb",
124+
borderRadius: 6,
125+
}
126+
: {};
127+
128+
return (
129+
<div
130+
ref={wrapRef}
131+
onMouseDown={onMouseDown}
132+
onMouseMove={onMouseMove}
133+
onMouseUp={onMouseUp}
134+
onMouseLeave={() => dragging && setDragging(false)}
135+
style={{
136+
position: "absolute",
137+
inset: 0,
138+
cursor: "crosshair",
139+
zIndex: 10,
140+
border: "2px dashed #0d9488",
141+
background: "rgba(13,148,136,0.06)",
142+
}}
143+
title="拖拽框选要截取的结构区域"
144+
>
145+
<div style={boxStyle} />
146+
{!box && (
147+
<span style={{ position: "absolute", top: 4, left: 6, fontSize: 11, color: "#0f766e", background: "#fff", padding: "2px 6px", borderRadius: 4 }}>
148+
拖拽框选结构区域(Esc 取消)
149+
</span>
150+
)}
151+
<button
152+
onClick={onCancel}
153+
style={{ position: "absolute", top: 4, right: 6, fontSize: 11, border: "1px solid #e5e7eb", borderRadius: 4, background: "#fff", cursor: "pointer" }}
154+
title="取消框选"
155+
>
156+
✕ 取消
157+
</button>
158+
{confirm && (
159+
<div
160+
onClick={(e) => e.stopPropagation()}
161+
style={{
162+
position: "absolute",
163+
inset: 0,
164+
background: "rgba(255,255,255,0.96)",
165+
zIndex: 20,
166+
display: "flex",
167+
flexDirection: "column",
168+
alignItems: "center",
169+
justifyContent: "center",
170+
gap: 8,
171+
}}
172+
>
173+
<div style={{ fontSize: 12, fontWeight: 600, color: "#111827" }}>确认截取此区域?</div>
174+
<div style={zoomStyle} />
175+
<div style={{ fontSize: 11, color: "#6b7280" }}>
176+
区域 {Math.round(confirm.w * 100)}% × {Math.round(confirm.h * 100)}%(相对整屏)
177+
</div>
178+
{error && <div style={{ fontSize: 11, color: "#dc2626" }}>{error}</div>}
179+
<div style={{ display: "flex", gap: 8 }}>
180+
<button
181+
onClick={() => { setConfirm(null); setBox(null); }}
182+
disabled={saving}
183+
style={{ border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 12, padding: "5px 14px" }}
184+
>
185+
重新框选
186+
</button>
187+
<button
188+
onClick={() => void save()}
189+
disabled={saving}
190+
style={{ border: "1px solid #0d9488", borderRadius: 6, background: "#0d9488", color: "#fff", cursor: "pointer", fontSize: 12, padding: "5px 14px" }}
191+
>
192+
{saving ? "保存中…" : "✓ 保存为结构图"}
193+
</button>
194+
</div>
195+
</div>
196+
)}
197+
</div>
198+
);
199+
}

‎app/src/components/ImageGallery.tsx‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ import { useCallback, useEffect, useState } from "react";
1111
import { invoke } from "@tauri-apps/api/core";
1212
import { convertFileSrc } from "@tauri-apps/api/core";
1313
import { listen } from "@tauri-apps/api/event";
14+
import ImagePreviewOverlay from "./ImagePreviewOverlay";
15+
import StructureImageSection from "./StructureImageSection";
1416

1517
const btn: React.CSSProperties = { padding: "4px 10px", cursor: "pointer", fontSize: 12 };
1618

1719
export default function ImageGallery({ sessionId }: { sessionId: number }) {
1820
const [images, setImages] = useState<string[]>([]);
1921
const [loading, setLoading] = useState(false);
2022
const [error, setError] = useState("");
23+
// v0.7.7(REQ-187 修复):参考图详情预览(点击缩略图 → 大图遮罩)
24+
const [preview, setPreview] = useState<string | null>(null);
2125

2226
const refresh = useCallback(async () => {
2327
setLoading(true);
@@ -78,7 +82,12 @@ export default function ImageGallery({ sessionId }: { sessionId: number }) {
7882
)}
7983
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 8 }}>
8084
{images.map((rel) => (
81-
<div key={rel} style={{ border: "1px solid #e5e7eb", borderRadius: 6, overflow: "hidden", position: "relative" }}>
85+
<div
86+
key={rel}
87+
onClick={() => setPreview(rel)}
88+
style={{ border: "1px solid #e5e7eb", borderRadius: 6, overflow: "hidden", position: "relative", cursor: "pointer" }}
89+
title="点击查看大图"
90+
>
8291
{baseUrl && (
8392
<img
8493
src={convertFileSrc(`${baseUrl}/${rel}`)}
@@ -90,7 +99,10 @@ export default function ImageGallery({ sessionId }: { sessionId: number }) {
9099
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "3px 6px", fontSize: 10, color: "#6b7280" }}>
91100
<span title={rel}>{rel.split("/")[1]?.replace(".webp", "") ?? rel}</span>
92101
<button
93-
onClick={() => void removeImage(rel)}
102+
onClick={(e) => {
103+
e.stopPropagation();
104+
void removeImage(rel);
105+
}}
94106
style={{ border: "none", background: "none", color: "#dc2626", cursor: "pointer", fontSize: 11 }}
95107
title="删除此图"
96108
>
@@ -100,6 +112,15 @@ export default function ImageGallery({ sessionId }: { sessionId: number }) {
100112
</div>
101113
))}
102114
</div>
115+
{preview && baseUrl && (
116+
<ImagePreviewOverlay
117+
src={convertFileSrc(`${baseUrl}/${preview}`)}
118+
title={preview}
119+
onClose={() => setPreview(null)}
120+
/>
121+
)}
122+
{/* v0.7.7(REQ-185):结构图区段(非线性结构图像持久化图库) */}
123+
<StructureImageSection sessionId={sessionId} baseUrl={baseUrl} />
103124
</div>
104125
);
105126
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* ImagePreviewOverlay — 图片大图预览遮罩(v0.7.7 REQ-187 修复:参考图/结构图共用)。
3+
*
4+
* @ai-context: 点击缩略图 → 遮罩 + contain 大图 + 标题信息栏;ESC / 点击遮罩 /
5+
* 关闭按钮退出。参考图集与结构图区段共用一处实现。
6+
*/
7+
import { useEffect } from "react";
8+
9+
interface Props {
10+
src: string;
11+
title?: string;
12+
onClose: () => void;
13+
}
14+
15+
export default function ImagePreviewOverlay({ src, title, onClose }: Props) {
16+
// ESC 关闭(遮罩点击在容器上处理)
17+
useEffect(() => {
18+
const onKey = (e: KeyboardEvent) => {
19+
if (e.key === "Escape") onClose();
20+
};
21+
window.addEventListener("keydown", onKey);
22+
return () => window.removeEventListener("keydown", onKey);
23+
}, [onClose]);
24+
25+
return (
26+
<div
27+
onClick={onClose}
28+
style={{
29+
position: "fixed",
30+
inset: 0,
31+
background: "rgba(15,23,42,0.75)",
32+
zIndex: 1000,
33+
display: "flex",
34+
flexDirection: "column",
35+
alignItems: "center",
36+
justifyContent: "center",
37+
cursor: "zoom-out",
38+
}}
39+
>
40+
<div
41+
onClick={(e) => e.stopPropagation()}
42+
style={{
43+
background: "#fff",
44+
borderRadius: 10,
45+
padding: 12,
46+
maxWidth: "92vw",
47+
maxHeight: "92vh",
48+
display: "flex",
49+
flexDirection: "column",
50+
cursor: "default",
51+
}}
52+
>
53+
<img
54+
src={src}
55+
alt={title ?? "预览"}
56+
style={{ maxWidth: "88vw", maxHeight: "82vh", objectFit: "contain", borderRadius: 6 }}
57+
/>
58+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8, fontSize: 12, color: "#6b7280" }}>
59+
{title && <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</span>}
60+
<button
61+
onClick={onClose}
62+
style={{ marginLeft: "auto", border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 12, padding: "3px 10px" }}
63+
>
64+
关闭(ESC)
65+
</button>
66+
</div>
67+
</div>
68+
</div>
69+
);
70+
}

0 commit comments

Comments
 (0)