Skip to content

Commit b72d470

Browse files
committed
feat(ui): 补齐六条未接线命令的展示面与生产调用点
§9 的补 UI 名单里,T18/T19 已交付 4 条(update_note_tags / set_tag_color / reset_tag_color / video_profile_for_spec —— 本单元已用探针独立复核,各 ≥1 生产调用点)。 本提交做剩下的 6 条(§C9.1 的「6 条独立面」),每条 = 展示面 + 生产调用点 + 判据: #34 analyze_session_command ⇒ components/session-detail/SessionAnalysisPanel.tsx(新,115 行) 入口 = 会话详情头「🔄 重新分析」;结果面板渲染后端三段(章节/重点/术语)。 装配:SessionDetailHeader.tsx(176→179,本件在 buttonMigration 的 35 文件名单内 ⇒ 按钮留在新件、宿主零新增 <Button>,不需要同步形状表 §C9.8)。载荷 { id }。 #37 delete_session_images_all ⇒ components/session-detail/GalleryBulkDelete.tsx(新,70 行) 入口 = 图集区「🗑 删除本场全部图片」+ ConfirmDialog 二次确认(列明保留项)。 装配:ImageGallery.tsx(123→127,同属 35 文件名单 ⇒ 同上)。载荷 { sessionId };取消不调 IPC。 #39 finish_session ⇒ components/StaleSessionRecoveryBar.tsx(新,86 行) 触发 = 机器可判:手里有会话 id ∧ live_session_status.active === false(查询失败不出入口)。 装配:ClassroomCapturePanel.tsx(297→299,净增 +2,余 1)。载荷 { id }。 #40 get_decision ⇒ components/session-detail/DecisionDetailPanel.tsx(新,68 行) 入口 = 决策日志行点击展开;详情走命令取数(不预填列表对象,保证「接上了」可测)。 装配:KnowledgeDecisionLog.tsx(116→120)。载荷 { id }。 #42 refine_session ⇒ session-detail/SessionRefineSection.tsx(66→96) 入口 = 「🛠 手动精修」,与既有 auto 版(🔬 课后精修 = auto_refine_session)并列不替换; 本件原生 <button> 已满 3/3 ⇒ 新入口走 Button 原语。sessionId 经 SessionViewHost 透传 (SessionViewHost.tsx 141→145 + SessionDetailPanel.tsx 222→223)。载荷 { sessionId }。 #47 update_knowledge_system ⇒ components/KnowledgeSystemEditPanel.tsx(新,96 行) 入口 = 体系详情「✎ 编辑体系」⇒ 名称/核心问题/状态三字段;未改字段传 null(不产生伪写)。 装配:KnowledgeDetailPanel.tsx(298→300,净增 +2,余 0 —— 已登记为贴边件)。 载荷 { id, name, coreQuestion, status }。 判据:components/batch7UiWiring.test.tsx(新,217 行)19 用例 —— 每条命令两条独立判据: ① 行为(入口 ⇒ 交互 ⇒ invoke 命令名与载荷逐字正确;失败走 StatusLine;确认框先于 IPC); ② 源码(先剥注释 ⇒ 该命令在生产侧恰 ≥1 调用点;负控 0 命中 + 正控 list_decisions 命中)。 冻结键(C9.12):5 个新件在六棘轮全 0 命中;KnowledgeDetailPanel 300/300 与 ClassroomCapturePanel 299/300 的逐键读数均未抬高(nativeButton/border/radius/shadow/gray/oob)。 同批读数:tsc 0 错 · 14 个守卫件 159 用例全绿 · line-limits --full exit 0 · registry 311/311/0。
1 parent fb73e31 commit b72d470

15 files changed

Lines changed: 707 additions & 4 deletions

‎app/src/components/ClassroomCapturePanel.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818
import { invoke } from "@tauri-apps/api/core";
1919
import AudioLevelMeter from "./AudioLevelMeter";
20+
import StaleSessionRecoveryBar from "./StaleSessionRecoveryBar";
2021
import { AUTO_RESUME_HINTS } from "../hooks/liveCaptureState";
2122
import type { CaptureActionKind } from "../hooks/useLiveCaptureControl";
2223
import type { PrepareState } from "../hooks/useClassroomHints";
@@ -289,6 +290,7 @@ export default function ClassroomCapturePanel({
289290
</button>
290291
</>
291292
)}
293+
{sessionId && !starting && <StaleSessionRecoveryBar sessionId={sessionId} onRecovered={() => onStatus("会话已收尾(结束会话)")} />}
292294
{sessionId && !starting && (
293295
<p style={{ fontSize: 11, color: "#6b7280", margin: "6px 0 0" }}>实时捕获中(可到「会话」页查看)</p>
294296
)}

‎app/src/components/ImageGallery.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { listen } from "@tauri-apps/api/event";
1414
import ImagePreviewOverlay from "./ImagePreviewOverlay";
1515
import StructureImageSection from "./StructureImageSection";
1616
import { Button, EmptyState, StatusLine } from "../ui/primitives";
17+
import GalleryBulkDelete from "./session-detail/GalleryBulkDelete";
1718

1819
export default function ImageGallery({ sessionId }: { sessionId: number }) {
1920
const [images, setImages] = useState<string[]>([]);
@@ -72,6 +73,9 @@ export default function ImageGallery({ sessionId }: { sessionId: number }) {
7273
<Button variant="secondary" size="md" busy={loading} onClick={() => void refresh()}>
7374
{loading ? "加载中…" : "⟳ 刷新"}
7475
</Button>
76+
{/* 批 7 T20(§9 #37):整场批量删(`delete_session_images_all`)+ 危险确认。
77+
本件在 `buttonMigration` 的 35 文件普查名单内 ⇒ 按钮留在新件里,本件只加装配行。 */}
78+
<GalleryBulkDelete sessionId={sessionId} count={images.length} onDeleted={() => void refresh()} />
7579
{error && <StatusLine kind="error">{error}</StatusLine>}
7680
</div>
7781
{images.length === 0 && !loading && (

‎app/src/components/KnowledgeDecisionLog.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { confirm } from "@tauri-apps/plugin-dialog";
1717
import type { DecisionKind, KnowledgeDecision } from "../types/knowledge";
1818
import { countUsedRefs, parseUsedRefs } from "../types/knowledge";
1919
import { EmptyState, StatusLine, Text } from "../ui/primitives";
20+
import DecisionDetailPanel from "./session-detail/DecisionDetailPanel";
2021

2122
interface Props {
2223
systemId: number;
@@ -40,6 +41,8 @@ export default function KnowledgeDecisionLog({ systemId, conceptId, onChanged }:
4041
const [decisions, setDecisions] = useState<KnowledgeDecision[]>([]);
4142
const [tab, setTab] = useState<DecisionKind>("decision");
4243
const [status, setStatus] = useState("");
44+
// 批 7 T20(§9 #40):单条展开详情 —— 点条目行 ⇒ `get_decision`(同一时刻只展开一条)
45+
const [openId, setOpenId] = useState<number | null>(null);
4346

4447
const load = useCallback(async () => {
4548
try {
@@ -95,8 +98,9 @@ export default function KnowledgeDecisionLog({ systemId, conceptId, onChanged }:
9598
<div key={d.id} data-testid={`decision-row-${d.id}`} style={{ display: "flex", alignItems: "flex-start", gap: 6, padding: "6px 0", borderBottom: "1px solid #f3f4f6" }}>
9699
<span data-testid={`decision-kind-${d.id}`} style={{ fontSize: 12 } as const}>{d.kind === "decision" ? "🧭" : "🛠"}</span>
97100
<div style={{ flex: 1, minWidth: 0 }}>
98-
<div data-testid={`decision-content-${d.id}`} style={{ fontSize: 12, color: "#374151", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{d.content}</div>
101+
<div data-testid={`decision-content-${d.id}`} onClick={() => setOpenId(openId === d.id ? null : d.id)} style={{ fontSize: 12, color: "#374151", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{d.content}</div>
99102
<Text as="div" tone="ink-3" style={{ fontSize: 11 }}>{formatDecisionTime(d.decidedAt)} · {refCount} 个引用</Text>
103+
{openId === d.id && <DecisionDetailPanel id={d.id} />}
100104
</div>
101105
<button data-testid={`decision-delete-${d.id}`} onClick={() => void remove(d.id)} title="删除记录" style={{ border: "none", background: "none", cursor: "pointer", fontSize: 13, color: "#9ca3af" }}>🗑</button>
102106
</div>

‎app/src/components/KnowledgeDetailPanel.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/**
1+
/**
22
* KnowledgeDetailPanel — 知识体系右栏详情面板(v0.13.x)。
33
*
44
* @ai-context: 按选中实体三态分派——节点(只读信息 + 引用)/概念(三问编辑)/
@@ -22,6 +22,7 @@ import KnowledgeLinkSection from "./KnowledgeLinkSection";
2222
import DiscoverySuggestSection from "./DiscoverySuggestSection";
2323
import KnowledgeDecisionForm from "./KnowledgeDecisionForm";
2424
import KnowledgeDecisionLog from "./KnowledgeDecisionLog";
25+
import KnowledgeSystemEditPanel from "./KnowledgeSystemEditPanel";
2526
import { StatusLine, Text } from "../ui/primitives";
2627

2728
interface Props {
@@ -145,6 +146,7 @@ export default function KnowledgeDetailPanel({ width = 320, system, nodes, conce
145146
<div data-testid="detail-panel" style={{ width, flexShrink: 0, borderLeft: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0, background: "#fff" }}>
146147
<div style={{ display: "flex", alignItems: "center", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>
147148
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>详情</span>
149+
<KnowledgeSystemEditPanel system={system} onChanged={onChanged} />
148150
<button data-testid="detail-collapse" onClick={() => setCollapsed(true)} title="折叠详情面板" style={{ marginLeft: "auto", border: "none", background: "none", cursor: "pointer", fontSize: 14, color: "#9ca3af" }}>
149151
▸
150152
</button>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* KnowledgeSystemEditPanel — 知识体系「编辑」(名称 / 核心问题 / 状态)(批 7 T20 · §9 #47)。
3+
*
4+
* @ai-context Why:`update_knowledge_system` 今天**零调用点** ⇒ 体系**无法改名 / 改核心问题 / 改状态**
5+
* (审计原文逐字称其为「**最明确的功能缺口**」)。规格 §9 的处置 =「体系改名 / 核心问题 / 状态」。
6+
* @ai-context 形态:入口按钮 + **就地展开的三字段表单**(不新开弹层 —— 体系详情面板本身已是右栏,
7+
* 再叠一层 `Modal` 会与它的折叠/展开语义打架;就地表单的退出 = 再点入口或按取消)。
8+
* 三字段一次提交(`name` / `coreQuestion` / `status`):后端三个参数都是 `Option`,
9+
* **未改的字段传 `null` 不传旧值**(避免「打开就写一遍」的伪写)。
10+
* @ai-context 冻结键口径(C9.12):新文件 ⇒ 六棘轮零字面量(按钮 = `Button`、`select` = 原生元素
11+
* 但**不是** `<button>`(`nativeButton` 只数按钮)· 错误行 = `StatusLine` · 排版 = `Text` 档位);
12+
* **不用 `EmptyState`**(`emptyStateRatchet` 余量集逐文件冻结)。宿主 `KnowledgeDetailPanel` 已 298/300
13+
* ⇒ 本件按「宿主只加两行」设计(import + 一行 JSX),**不吃它的余量**。
14+
* 副作用:一次 `invoke("update_knowledge_system")`。边界:① 三字段**全未改** ⇒ 不发 IPC(直接收起);
15+
* ② 成功后回调 `onChanged`(宿主重拉体系)并收起;③ 失败 ⇒ 错误行 + **保持展开**(不丢用户输入)。
16+
*/
17+
import { useState } from "react";
18+
import { invoke } from "@tauri-apps/api/core";
19+
import type { KnowledgeSystem } from "../types/knowledge";
20+
import { Button, StatusLine, Text } from "../ui/primitives";
21+
22+
/** 体系状态三档(后端 `require_status("system", …)` 的白名单;中文标签只在这里出一次) */
23+
const STATUS_LABEL: Readonly<Record<string, string>> = { active: "进行中", watching: "观察中", archived: "已归档" };
24+
const INPUT = { fontSize: 12, padding: "3px 6px", width: "100%" } as const;
25+
26+
export default function KnowledgeSystemEditPanel({ system, onChanged }: {
27+
readonly system: KnowledgeSystem;
28+
/** 保存成功后的回调(宿主重拉体系;本件不自己改宿主的对象) */
29+
readonly onChanged: () => void;
30+
}) {
31+
const [open, setOpen] = useState(false);
32+
const [name, setName] = useState(system.name);
33+
const [coreQuestion, setCoreQuestion] = useState(system.coreQuestion ?? "");
34+
const [status, setStatus] = useState<string>(system.status);
35+
const [busy, setBusy] = useState(false);
36+
const [err, setErr] = useState("");
37+
38+
/** 展开时把三个受控值同步到**当前**体系(换选中项后不留上一项的半截输入) */
39+
const toggle = (): void => {
40+
if (!open) {
41+
setName(system.name);
42+
setCoreQuestion(system.coreQuestion ?? "");
43+
setStatus(system.status);
44+
setErr("");
45+
}
46+
setOpen(!open);
47+
};
48+
49+
const save = async (): Promise<void> => {
50+
const nextName = name.trim() === system.name ? null : name.trim();
51+
const nextCore = coreQuestion.trim() === (system.coreQuestion ?? "") ? null : coreQuestion.trim();
52+
const nextStatus = status === system.status ? null : status;
53+
if (nextName === null && nextCore === null && nextStatus === null) {
54+
setOpen(false); // 边界①:什么都没改 ⇒ 不发 IPC
55+
return;
56+
}
57+
setBusy(true);
58+
setErr("");
59+
try {
60+
await invoke("update_knowledge_system", { id: system.id, name: nextName, coreQuestion: nextCore, status: nextStatus });
61+
setOpen(false);
62+
onChanged();
63+
} catch (e) {
64+
setErr(`保存失败: ${e}`);
65+
} finally {
66+
setBusy(false);
67+
}
68+
};
69+
70+
return (
71+
<span data-testid="knowledge-system-edit">
72+
<Button size="sm" variant="secondary" onClick={toggle} title="编辑体系名称 / 核心问题 / 状态">
73+
{open ? "收起编辑" : "✎ 编辑体系"}
74+
</Button>
75+
{open && (
76+
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 6 }}>
77+
<Text as="p" size={5} tone="ink-3" style={{ margin: 0 }}>名称</Text>
78+
<input aria-label="体系名称" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} style={INPUT} />
79+
<Text as="p" size={5} tone="ink-3" style={{ margin: 0 }}>核心问题</Text>
80+
<input aria-label="核心问题" value={coreQuestion} disabled={busy} onChange={(e) => setCoreQuestion(e.target.value)} style={INPUT} />
81+
<Text as="p" size={5} tone="ink-3" style={{ margin: 0 }}>状态</Text>
82+
<select aria-label="体系状态" value={status} disabled={busy} onChange={(e) => setStatus(e.target.value)} style={INPUT}>
83+
{Object.keys(STATUS_LABEL).map((k) => (
84+
<option key={k} value={k}>{STATUS_LABEL[k]}</option>
85+
))}
86+
</select>
87+
{err !== "" && <StatusLine kind="error" testId="knowledge-system-edit-error">{err}</StatusLine>}
88+
<span style={{ display: "flex", gap: 6 }}>
89+
<Button size="sm" variant="secondary" busy={busy} onClick={() => void save()}>保存</Button>
90+
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>取消</Button>
91+
</span>
92+
</div>
93+
)}
94+
</span>
95+
);
96+
}

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ export default function SessionDetailPanel({ detail, views, fusing, degradedBann
202202
}
203203
refining={refining}
204204
onStartRefine={startRefine}
205+
sessionId={detail.session.id}
205206
canSecondPass={detail.session.status === "finished" && detail.session.kind !== "photo"}
206207
onOpenPass2={() => setShowPass2(true)}
207208
onOpenProofread={() => setShowProofread(true)}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* StaleSessionRecoveryBar — 「结束会话」的恢复通道(批 7 T20 · §9 #39)。
3+
*
4+
* @ai-context Why 需要它:`finish_session` 今天**零调用点** ⇒ 崩溃/管线中断后卡在 `recording` 的会话
5+
* **没有 UI 出口**(只能靠后端 `mark_stale_recording` 兜底)。规格 §9 的处置逐字是「恢复动作
6+
* 『结束会话』(崩溃后卡在录制态的收尾通道)」。
7+
* @ai-context 🔴 触发条件**必须是机器可判的**(计划的硬要求,不许「先接上再说」):
8+
* 我们手里**还有**一个会话 id(`sessionId != null`)**而后端 `live_session_status.active === false`**
9+
* ⇒ 「前端认为这场还在,后端已经没有活动会话」= 传输/引擎链路已经掉了,会话卡在录制态。
10+
* ⚠️ **口径偏离(逐字登记,见报告 §6)**:计划写的是「`live_session_status` 返回 **active 但无心跳**」,
11+
* 而该命令的实际返回**没有心跳字段**(`commands_live.rs:277`:`active` / `session_id` / `prepared` /
12+
* `paused` / `paused_reason` / `tier`)⇒ 「active 但无心跳」**不可实现**;本件取计划括注里的
13+
* 另一条授权口径「**或前端已知 stale**」,并用「UI 持有 id ∧ 后端 active=false」表达它。
14+
* @ai-context 冻结键口径(C9.12):新文件 ⇒ 六棘轮零字面量(按钮 = `Button`、错误行 = `StatusLine`、
15+
* 排版 = `Text`);**不用 `EmptyState`**(`emptyStateRatchet` 余量集逐文件冻结)。
16+
* 副作用:挂载时一次 `invoke("live_session_status")`(只读)+ 用户点击时一次
17+
* `invoke("finish_session", { id })`。边界:① `sessionId === null` ⇒ **不查询、不渲染**(干净挂载);
18+
* ② 查询失败 ⇒ **不渲染**(宁可不出,也不在没有依据时给一个「结束会话」的危险入口);
19+
* ③ 成功后回调宿主刷新(本件不自己写会话列表的 state)。
20+
*/
21+
import { useCallback, useEffect, useState } from "react";
22+
import { invoke } from "@tauri-apps/api/core";
23+
import { Button, StatusLine, Text } from "../ui/primitives";
24+
25+
/** 后端 `live_session_status` 的返回(`commands_live.rs:277` 逐字字段;无心跳字段) */
26+
interface LiveSessionStatus {
27+
readonly active: boolean;
28+
readonly session_id: number | null;
29+
}
30+
31+
export default function StaleSessionRecoveryBar({ sessionId, onRecovered }: {
32+
/** 前端当前持有的会话 id(null ⇒ 本件什么都不做) */
33+
readonly sessionId: number | null;
34+
/** 收尾成功后的回调(宿主重拉会话/状态;本件不猜宿主的状态源) */
35+
readonly onRecovered: () => void;
36+
}) {
37+
const [stale, setStale] = useState(false);
38+
const [busy, setBusy] = useState(false);
39+
const [err, setErr] = useState("");
40+
41+
const probe = useCallback(async (): Promise<void> => {
42+
if (sessionId === null) {
43+
setStale(false);
44+
return;
45+
}
46+
try {
47+
const st = await invoke<LiveSessionStatus>("live_session_status");
48+
setStale(st.active === false);
49+
} catch {
50+
setStale(false); // 查询失败 ⇒ 不出危险入口(边界②)
51+
}
52+
}, [sessionId]);
53+
54+
useEffect(() => {
55+
void probe();
56+
}, [probe]);
57+
58+
const finish = async (): Promise<void> => {
59+
if (sessionId === null) return;
60+
setBusy(true);
61+
setErr("");
62+
try {
63+
await invoke<boolean>("finish_session", { id: sessionId });
64+
setStale(false);
65+
onRecovered();
66+
} catch (e) {
67+
setErr(`结束会话失败: ${e}`);
68+
} finally {
69+
setBusy(false);
70+
}
71+
};
72+
73+
// 🔴 机器可判的两个门:手里有 id ∧ 后端已无活动会话。两条都不满足 ⇒ 一个字都不渲染。
74+
if (!stale || sessionId === null) return null;
75+
return (
76+
<div data-testid="stale-session-recovery">
77+
<Text as="p" size={5} tone="ink-3" style={{ margin: "6px 0 2px" }}>
78+
会话 #{sessionId} 仍停在「录制中」,但后端已无活动采集 ⇒ 它需要收尾。
79+
</Text>
80+
{err !== "" && <StatusLine kind="error" testId="stale-session-error">{err}</StatusLine>}
81+
<Button size="sm" variant="secondary" busy={busy} onClick={() => void finish()} title="把这场会话标记为已结束(保留已落库的转写与画面)">
82+
{busy ? "收尾中…" : "⏹ 结束会话"}
83+
</Button>
84+
</div>
85+
);
86+
}

0 commit comments

Comments
 (0)