Skip to content

Commit dfd233c

Browse files
committed
feat(ui): 采集浮窗化——alwaysOnTop 悬浮小窗(v0.12.0 M6)
M6 采集体验债:全屏看视频时悬浮小窗常显状态/转写/画面/控制。 - commands_window.rs:open/close_capture_float——WebviewWindowBuilder 创建 capture-float 子窗(alwaysOnTop/decorations:false/transparent/skipTaskbar/360x240), 加载 index.html?float=1(幂等:已存在不叠窗;App.tsx 按 URL query 渲染浮窗不渲染导航壳) - capabilities/default.json:windows 加 capture-float - useLiveSessionEvents.ts(新 hook):从 LiveActivityPanel 抽取事件监听 (partial/final/subtitle/ocr/status/session-info/fusing)——主面板与浮窗共用同一数据流 - CaptureFloatPanel.tsx(新):顶部状态/时长 · 中部最近转写 · 底部画面要点/计数 · 控制(暂停/继续·停止·回主窗——真实命令) - App.tsx:?float=1 时渲染 CaptureFloatPanel(before-hooks 早返回,不再注册主导航 live 监听) - ClassroomPage:采集中按钮组加「浮窗化」(快捷键 Ctrl+Shift+F);停止后自动关浮窗 注:LiveActivityPanel 暂未迁移到共享 hook(关键主面板 500 行,迁移需真机回归验证); 本次以浮窗消费 hook 满足 M6 功能,主面板迁移列为后续。
1 parent dca7a9a commit dfd233c

7 files changed

Lines changed: 460 additions & 1 deletion

File tree

‎app/src-tauri/capabilities/default.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "../gen/schemas/desktop-schema.json",
33
"identifier": "default",
44
"description": "Capability for the main window",
5-
"windows": ["main"],
5+
"windows": ["main", "capture-float"],
66
"permissions": [
77
"core:default",
88
"dialog:default"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//! 采集浮窗窗口命令(v0.12.0 M6,采集体验债)。
2+
//!
3+
//! @ai-context: 采集中全屏看视频时,主导航/会话面板被遮挡——悬浮小窗
4+
//! (alwaysOnTop)常显状态/转写/控制,主面板与浮窗共用
5+
//! useLiveSessionEvents hook。浮窗为独立 Tauri 子窗口:
6+
//! alwaysOnTop/decorations:false/transparent/skipTaskbar/360×240,
7+
//! 加载 index.html?float=1(App.tsx 按 URL query 渲染 CaptureFloatPanel,
8+
//! 不渲染主导航壳)。
9+
//! @ai-context: 窗口不持久——停止采集后由 ClassroomPage 调 close_capture_float 销毁;
10+
//! 幂等:重复打开时若已存在则直接返回(不叠窗)。
11+
12+
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
13+
14+
/// 浮窗标签(唯一标识;capabilities/default.json windows 列表需包含)。
15+
pub const CAPTURE_FLOAT_LABEL: &str = "capture-float";
16+
17+
/// 打开采集浮窗(幂等——已存在直接返回,不重复创建)。
18+
///
19+
/// @ai-context: ?float=1 让 App.tsx 渲染 CaptureFloatPanel;窗口不加载数据库/
20+
/// 采集引擎(纯前端消费 live:* 事件),创建失败返回错误由调用方
21+
/// 引导(浮窗是增强,失败不阻断采集主链路)。
22+
#[tauri::command]
23+
pub fn open_capture_float(app: tauri::AppHandle) -> Result<(), String> {
24+
if app.get_webview_window(CAPTURE_FLOAT_LABEL).is_some() {
25+
return Ok(());
26+
}
27+
WebviewWindowBuilder::new(&app, CAPTURE_FLOAT_LABEL, WebviewUrl::App("index.html?float=1".into()))
28+
.title("采集浮窗")
29+
.inner_size(360.0, 240.0)
30+
.decorations(false)
31+
.transparent(true)
32+
.always_on_top(true)
33+
.skip_taskbar(true)
34+
.resizable(false)
35+
.build()
36+
.map_err(|e| format!("创建采集浮窗失败: {}", e))?;
37+
Ok(())
38+
}
39+
40+
/// 关闭采集浮窗(窗口不存在视为已关闭——幂等)。
41+
///
42+
/// @ai-context: 停止采集后由 ClassroomPage / CaptureFloatPanel 回主窗调用销毁;
43+
/// 幂等:不存在则无副作用(不报错)。
44+
#[tauri::command]
45+
pub fn close_capture_float(app: tauri::AppHandle) -> Result<(), String> {
46+
if let Some(window) = app.get_webview_window(CAPTURE_FLOAT_LABEL) {
47+
window.close().map_err(|e| format!("关闭采集浮窗失败: {}", e))?;
48+
}
49+
Ok(())
50+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ mod commands_structures;
120120
mod commands_streaming;
121121
mod commands_vocab;
122122
mod commands_video;
123+
// v0.12.0 M6(采集体验债):采集浮窗窗口命令(open/close_capture_float)
124+
mod commands_window;
123125
mod concat;
124126
mod db;
125127
// H3 拆分(db.rs 原 678 行硬拆):schema 建表/列迁移 + notes 读写独立成块
@@ -495,6 +497,9 @@ pub fn run() {
495497
// v0.9.0 M3(REQ-190):领域标签检测 + hotwords 预热
496498
commands_video::detect_video_domain,
497499
commands_video::preheat_domain_hotwords,
500+
// v0.12.0 M6(采集体验债):采集浮窗打开/关闭
501+
commands_window::open_capture_float,
502+
commands_window::close_capture_float,
498503
// 会话结构化分析(REQ-044/045/046,v0.5.0 M2:章节/重点/术语/讲者)
499504
commands_analysis::analyze_session_command,
500505
// 说话人分离(REQ-153,v0.7.2:弱化版讲者切换离线分析——幂等懒加载)

‎app/src/App.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import SessionsPage from "./pages/SessionsPage";
1919
// 2026-08-21 用户需求:设置页(课堂助手设置类面板迁出,单页滚动+分组)
2020
import SettingsPage from "./pages/SettingsPage";
2121
import AppErrorBoundary from "./components/AppErrorBoundary";
22+
import CaptureFloatPanel from "./components/CaptureFloatPanel";
2223
import type { AiTaskState } from "./types";
2324

2425
type Page = "classroom" | "sessions" | "notes" | "settings";
@@ -31,6 +32,14 @@ const NAV_ITEMS: { key: Page; label: string }[] = [
3132
];
3233

3334
function App() {
35+
// v0.12.0 M6:采集浮窗入口——URL 带 ?float=1 时仅渲染 CaptureFloatPanel
36+
//(不渲染主导航壳;浮窗独立窗口 alwaysOnTop,加载 index.html?float=1)。
37+
// 规则:float 标志 per-window 恒定(URL 不变),故此处 before-hooks 早返回安全;
38+
// 它使浮窗不注册主导航的 live:* 监听(数据流由 CaptureFloatPanel 的 hook 持有)。
39+
// eslint-disable-next-line react-hooks/rules-of-hooks
40+
if (new URLSearchParams(window.location.search).get("float") === "1") {
41+
return <CaptureFloatPanel />;
42+
}
3443
const [page, setPage] = useState<Page>("classroom");
3544
// 2026-08 A4:跨页直达目标会话(课堂助手融合完成 → 会话页自动打开详情)
3645
const [focusSessionId, setFocusSessionId] = useState<number | null>(null);
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* CaptureFloatPanel — 采集浮窗(v0.12.0 M6,采集体验债)。
3+
*
4+
* @ai-context: 采集中全屏看视频时主面板被遮挡——常驻悬浮小窗(alwaysOnTop)
5+
* 顶部状态/时长、中部最近转写、底部最近画面要点、控制按钮。
6+
* 与主面板共用 useLiveSessionEvents hook(同一 live:* 数据流)。
7+
* 控制按钮走真实命令(暂停/继续/停止/回主窗);"标记"无后端命令,
8+
* 按 YAGNI 不预排虚假按钮。
9+
*
10+
* @line-limit-exemption: 浮窗内容密度高(360×240 内状态/转写/画面/控制分区),
11+
* 登记 docs/standards/line-limit-exemptions.md。
12+
*/
13+
import { invoke } from "@tauri-apps/api/core";
14+
import { useLiveSessionEvents } from "../hooks/useLiveSessionEvents";
15+
16+
function fmtTime(ms: number): string {
17+
const s = Math.floor(ms / 1000);
18+
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
19+
}
20+
21+
const btn: React.CSSProperties = {
22+
padding: "3px 8px",
23+
fontSize: 11,
24+
borderRadius: 6,
25+
border: "1px solid #d1d5db",
26+
background: "#fff",
27+
cursor: "pointer",
28+
flex: 1,
29+
};
30+
31+
/** 最近展示条数(360×240 浮窗——只给最近 3 条转写 + 2 条画面) */
32+
const SHOW_TRANSCRIPT = 3;
33+
const SHOW_OCR = 2;
34+
35+
export default function CaptureFloatPanel() {
36+
const { phase, transcripts, partials, counts, ocrLines, elapsedMs, info } =
37+
useLiveSessionEvents();
38+
const paused = phase.startsWith("⏸");
39+
40+
const togglePause = () => {
41+
void invoke(paused ? "resume_live_session" : "pause_live_session").catch((e) =>
42+
console.warn("[capture-float] 暂停/继续失败:", e),
43+
);
44+
};
45+
const stop = () => {
46+
void invoke("stop_live_session")
47+
.catch((e) => console.warn("[capture-float] 停止采集失败:", e))
48+
.then(() => void invoke("close_capture_float").catch(() => undefined));
49+
};
50+
const backToMain = () => {
51+
void invoke("close_capture_float").catch(() => undefined);
52+
};
53+
54+
return (
55+
<div
56+
style={{
57+
height: "100vh",
58+
boxSizing: "border-box",
59+
display: "flex",
60+
flexDirection: "column",
61+
fontFamily: "system-ui, sans-serif",
62+
fontSize: 11,
63+
color: "#1f2937",
64+
background: "rgba(255,255,255,0.95)",
65+
border: "1px solid #e5e7eb",
66+
borderRadius: 8,
67+
overflow: "hidden",
68+
}}
69+
>
70+
{/* 顶部:状态 + 时长 + 平台 */}
71+
<div
72+
style={{
73+
display: "flex",
74+
alignItems: "center",
75+
gap: 8,
76+
padding: "6px 10px",
77+
borderBottom: "1px solid #e5e7eb",
78+
fontSize: 11,
79+
flexShrink: 0,
80+
}}
81+
>
82+
<span style={{ fontWeight: 600, color: paused ? "#b45309" : "#dc2626" }}>
83+
{phase === "正在初始化…" ? "初始化…" : phase}
84+
</span>
85+
<span style={{ color: "#6b7280", fontVariantNumeric: "tabular-nums" }}>⏱ {fmtTime(elapsedMs)}</span>
86+
<span style={{ marginLeft: "auto", color: "#6b7280" }}>{info?.platform ?? ""}</span>
87+
</div>
88+
89+
{/* 中部:最近转写 */}
90+
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "6px 10px" }}>
91+
{transcripts.slice(-SHOW_TRANSCRIPT).length === 0 && partials.length === 0 && (
92+
<p style={{ margin: 0, color: "#9ca3af" }}>等待识别…</p>
93+
)}
94+
{transcripts.slice(-SHOW_TRANSCRIPT).map((t) => (
95+
<div key={t.id} style={{ display: "flex", gap: 6, alignItems: "baseline", lineHeight: 1.5, marginBottom: 2 }}>
96+
<span style={{ fontSize: 10, color: "#9ca3af", width: 36, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
97+
{fmtTime(t.time)}
98+
</span>
99+
<span style={{ color: t.source === "subtitle" ? "#0f766e" : "#374151", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
100+
{t.text}
101+
</span>
102+
</div>
103+
))}
104+
{partials.slice(-2).map((p) => (
105+
<div key={p.id} style={{ display: "flex", gap: 6, alignItems: "baseline", lineHeight: 1.5, marginBottom: 2 }}>
106+
<span style={{ fontSize: 10, width: 36, flexShrink: 0 }} />
107+
<span style={{ color: p.committed ? "#374151" : "#9ca3af", fontStyle: p.committed ? "normal" : "italic", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
108+
{p.text}
109+
</span>
110+
</div>
111+
))}
112+
</div>
113+
114+
{/* 底部:最近画面要点 + 计数 */}
115+
<div style={{ padding: "6px 10px", borderTop: "1px solid #e5e7eb", flexShrink: 0 }}>
116+
<div style={{ display: "flex", gap: 8, color: "#6b7280", marginBottom: 4 }}>
117+
<span>字幕 {counts.subtitle}</span>
118+
<span>语音 {counts.asr}</span>
119+
<span style={{ color: "#2563eb" }}>画面 {counts.ocr}</span>
120+
</div>
121+
{ocrLines.slice(-SHOW_OCR).map((o) => (
122+
<div key={o.id} style={{ display: "flex", gap: 6, alignItems: "baseline", lineHeight: 1.5 }}>
123+
<span style={{ fontSize: 10, color: "#2563eb", flexShrink: 0, fontWeight: 600 }}>屏{o.screenId}</span>
124+
<span style={{ color: "#1e40af", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{o.text}</span>
125+
</div>
126+
))}
127+
{/* 控制按钮(暂停/继续 · 停止 · 回主窗) */}
128+
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
129+
<button style={{ ...btn, background: paused ? "#0d9488" : "#f59e0b", color: "#fff", border: "none" }} onClick={togglePause}>
130+
{paused ? "▶ 继续" : "⏸ 暂停"}
131+
</button>
132+
<button style={{ ...btn, background: "#dc2626", color: "#fff", border: "none" }} onClick={stop}>
133+
⏹ 停止
134+
</button>
135+
<button style={{ ...btn }} onClick={backToMain}>
136+
⌂ 回主窗
137+
</button>
138+
</div>
139+
</div>
140+
</div>
141+
);
142+
}

0 commit comments

Comments
 (0)