Skip to content

Commit fd83abb

Browse files
committed
feat(shell): rebuild top bar, float ai toast
1 parent 92ea5d6 commit fd83abb

6 files changed

Lines changed: 596 additions & 125 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 105 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
* ② 监听 app:close-requested(Rust 侧拦截了关闭)→ 确认框 →
1212
* 确认后 stop_live_session 再 close,取消则采集继续。
1313
* ai:task-update 全局 toast(REQ-145 第二通道)与采集状态无关,保留。
14+
* @ai-context: 批 3 T7:顶栏不再在本文件的行内 JSX 里 —— 它提成 `shell/TopBar`(两档溢出是媒体
15+
* 查询的活,内联 style 表达不了);AI toast 则从导航行搬到本层的 **fixed 覆盖层**
16+
* (控制方裁决 A3:它是 1024 溢出的唯一主因,单项 373.75 px = 视口的 36.5%)。
17+
* 本文件负责装配、跨页状态与这层壳级覆盖层。
1418
*/
1519
import { lazy, Suspense, useEffect, useRef, useState } from "react";
1620
import { listen } from "@tauri-apps/api/event";
@@ -23,7 +27,15 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
2327
// 「从未访问过的页不进首屏 module graph」与「访问过的页常驻、永不卸载(TD-004)」两条语义
2428
// 都逐字保留(保活由下面的 PageSlot + mountedPages 实施,本任务未动它们一个字节)。
2529
// @ai-context: 仍不引入路由库(规格 §3 红线 2):入口依旧是 useState<Page>。
26-
import { ALL_ENTRIES, navComponent, type PageKey } from "./shell/navRegistry";
30+
import { navComponent, type PageKey } from "./shell/navRegistry";
31+
// 批 3 T7:A′ 顶栏(规格 §6.1)从本文件的 95 行内联 `<nav>` 提成独立组件 —— 两档溢出是媒体查询的活,
32+
// 内联 style 表达不了;顶栏的宽度也因此第一次有了可测的单一落点(TopBar.tsx / TopBar.css)。
33+
import { TopBar, TopBarAction } from "./shell/TopBar";
34+
// 批 3 T7(控制方裁决 A3):AI toast 从 56px 导航行搬到**固定覆盖层** —— 层级走六档标尺,
35+
// 不许写裸数字(`ui/zIndex.guard.test.ts` 在看着)。实测 toast 是 1024 溢出的唯一主因
36+
// (单项 373.75 px = 视口的 36.5%;含它 1375.74 px、剔除它 997.99 px),而规格 §6.1 的顶栏
37+
// 清单里本来就没有它;自足内联实现(不 import `ui/primitives`)⇒ 不吃首屏余量。
38+
import { zIndex } from "./ui/zIndex";
2739
// REQ-274(v0.19.4):全局 AI 对话面板(丙案——按需唤起 + 内容保活)
2840
// 批 2 包体治理:从静态 import 改为按需 import,并在它之前加一道「首开挂载」闸门。
2941
// @ai-context: 原语义(见文件末尾 dock 渲染处的注释)是「常驻挂载——开合仅切 display,
@@ -67,8 +79,9 @@ const KnowledgePage = navComponent("knowledge");
6779
const GoalsPage = navComponent("goals");
6880
const SettingsPage = navComponent("settings");
6981

70-
// 顶栏项 = 注册表 map 出来(label 暂用 legacyLabel:T6 零观感变化,T7 切到 e.label 并删该列)
71-
const NAV_ITEMS: { key: Page; label: string }[] = ALL_ENTRIES.map((e) => ({ key: e.key, label: e.legacyLabel }));
82+
// 顶栏项不再在本文件里另立清单(批 3 T7):`NAV_ITEMS`(含 emoji 长名的派生态)已删除,
83+
// 顶栏直接消费注册表的 `label` / `icon`(规格 §6.1 的短名 + §1 决策 5 的自绘线性图标)。
84+
// 冻结判据在 `shell/navRegistry.test.ts` 第 ⑤ 层(顺序 + label 逐字),T7 起值 = 纯文字(emoji 出局)。
7285

7386
function App() {
7487
// URL per-window 标志早返回(不渲染主导航壳)。批 2b 采集控制单一状态源:
@@ -266,102 +279,59 @@ function MainShell() {
266279
<div style={{ height: "100vh", display: "flex", flexDirection: "column", fontFamily: "system-ui, sans-serif" }}>
267280
{/* v0.16.1:浏览器痕迹去除——原生右键菜单抑制 + 文本输入应用内右键小菜单 */}
268281
<BrowserChrome />
269-
{/* 顶部导航 */}
270-
<nav
271-
style={{
272-
height: "var(--ed-nav-h)",
273-
flexShrink: 0,
274-
display: "flex",
275-
alignItems: "center",
276-
gap: 4,
277-
padding: "0 16px",
278-
borderBottom: "1px solid #e5e7eb",
279-
background: "#fff",
280-
}}
281-
>
282-
<span style={{ fontWeight: 700, fontSize: 15, marginRight: 20 }}>熵减 · 本地知识提取</span>
283-
{NAV_ITEMS.map((item) => (
284-
<button
285-
key={item.key}
286-
onClick={() => setPage(item.key)}
287-
style={{
288-
padding: "8px 16px",
289-
fontSize: 13,
290-
fontWeight: page === item.key ? 600 : 400,
291-
color: page === item.key ? "#0d9488" : "#4b5563",
292-
background: page === item.key ? "#f0fdfa" : "transparent",
293-
border: "none",
294-
borderBottom: page === item.key ? "2px solid #0d9488" : "2px solid transparent",
295-
cursor: "pointer",
296-
}}
297-
>
298-
{item.label}
299-
</button>
300-
))}
301-
{/* REQ-274:全局 AI 对话面板入口(按需唤起;Ctrl+Shift+A 等效) */}
302-
<button
303-
data-testid="dock-toggle"
304-
onClick={() => setDockOpen((v) => !v)}
305-
title="AI 任务对话面板(Ctrl+Shift+A)"
306-
style={{
307-
marginLeft: "auto",
308-
padding: "6px 12px", fontSize: 12, cursor: "pointer",
309-
border: dockOpen ? "1px solid #4f46e5" : "1px solid #d1d5db",
310-
background: dockOpen ? "#eef2ff" : "#fff",
311-
color: dockOpen ? "#3730a3" : "#374151", borderRadius: 8,
312-
}}
313-
>
314-
🤖 对话面板
315-
</button>
316-
{/* 全局采集徽标(ADR-007):切页/最小化后仍可见采集状态。
317-
批 2b:paused → pausedReason 三态文案(manual/media/foreground 同源,
318-
与右栏/浮窗一致);recovering 与 paused 组合文案保持原语义 */}
319-
{capture.active && (
320-
<span
321-
style={{
322-
marginLeft: "auto",
323-
fontSize: 12,
324-
fontWeight: 600,
325-
color: capture.pausedReason || capture.recovering ? "#b45309" : "#0d9488",
326-
background: capture.pausedReason || capture.recovering ? "#fffbeb" : "#f0fdfa",
327-
border: `1px solid ${capture.pausedReason || capture.recovering ? "#f59e0b" : "#14b8a6"}`,
328-
borderRadius: 12,
329-
padding: "3px 10px",
330-
}}
331-
>
332-
{/* 审查修复(观察 2026-08-29-2):恢复态文案区分"暂停挂起"——
333-
暂停期重连风暴曾显示"采集中/恢复中"误导,现在明确"暂停中"语义 */}
334-
{capture.recovering
335-
? capture.pausedReason
336-
? "⏸ 暂停挂起(重连中)"
337-
: "⚠️ 采集恢复中"
338-
: capture.pausedReason
339-
? pauseReasonLabel(capture.pausedReason)
340-
: "🎙 采集中"}
341-
</span>
342-
)}
343-
{/* v0.8.0 F2:AI 任务完成通知(全局 toast——跨页面可见) */}
344-
{aiToast && (
345-
<span
346-
style={{
347-
marginLeft: capture.active ? 8 : "auto",
348-
fontSize: 12,
349-
fontWeight: 500,
350-
color: aiToast.kind === "ok" ? "#047857" : "#b91c1c",
351-
background: aiToast.kind === "ok" ? "#ecfdf5" : "#fef2f2",
352-
border: `1px solid ${aiToast.kind === "ok" ? "#a7f3d0" : "#fecaca"}`,
353-
borderRadius: 12,
354-
padding: "3px 10px",
355-
maxWidth: 420,
356-
overflow: "hidden",
357-
textOverflow: "ellipsis",
358-
whiteSpace: "nowrap",
359-
}}
360-
>
361-
{aiToast.text}
362-
</span>
363-
)}
364-
</nav>
282+
{/* 批 3 T7:A′ 顶栏(规格 §6.1)。原内联 9-Tab + 双 `marginLeft:auto` 布局已删除,
283+
改为 `shell/TopBar`(8 个域 Tab + ⌘K + ⚙ 齿轮,两档溢出走媒体查询)。
284+
`right` 插槽里是**两个常驻状态件**(都是已接线功能,不许在本步消失):
285+
· `dock-toggle` —— REQ-274 全局 AI 对话面板的**唯一显式入口**(T11 决定它进命令面板还是留在顶栏);
286+
· 采集徽标 —— ADR-007,切页/最小化后仍可见采集状态(规格 §6.1 明确列了「采集状态」)。
287+
AI toast **不在**这里:它是本文件下方 `MainShell` 最外层渲染的 fixed 覆盖层(裁决 A3)。 */}
288+
<TopBar
289+
page={page}
290+
onSelect={(key) => setPage(key)}
291+
onOpenSettings={() => setPage("settings")}
292+
// T11 接线命令面板(⌘K 面板与它的数据源都在 T11/T12;本步按钮就位、回调留空)
293+
onOpenPalette={() => {}}
294+
right={
295+
<>
296+
<TopBarAction
297+
testId="dock-toggle"
298+
icon="ai"
299+
label="对话面板"
300+
title="AI 任务对话面板(Ctrl+Shift+A)"
301+
pressed={dockOpen}
302+
onClick={() => setDockOpen((v) => !v)}
303+
/>
304+
{/* 全局采集徽标(ADR-007):切页/最小化后仍可见采集状态。
305+
批 2b:paused → pausedReason 三态文案(manual/media/foreground 同源,
306+
与右栏/浮窗一致);recovering 与 paused 组合文案保持原语义。
307+
T7:`marginLeft:auto` 删除 —— 定位由 `.ed-topbar__right`(整块 margin-left:auto)负责。 */}
308+
{capture.active && (
309+
<span
310+
style={{
311+
fontSize: 12,
312+
fontWeight: 600,
313+
color: capture.pausedReason || capture.recovering ? "#b45309" : "#0d9488",
314+
background: capture.pausedReason || capture.recovering ? "#fffbeb" : "#f0fdfa",
315+
border: `1px solid ${capture.pausedReason || capture.recovering ? "#f59e0b" : "#14b8a6"}`,
316+
borderRadius: 12,
317+
padding: "3px 10px",
318+
whiteSpace: "nowrap",
319+
}}
320+
>
321+
{/* 审查修复(观察 2026-08-29-2):恢复态文案区分"暂停挂起"——
322+
暂停期重连风暴曾显示"采集中/恢复中"误导,现在明确"暂停中"语义 */}
323+
{capture.recovering
324+
? capture.pausedReason
325+
? "⏸ 暂停挂起(重连中)"
326+
: "⚠️ 采集恢复中"
327+
: capture.pausedReason
328+
? pauseReasonLabel(capture.pausedReason)
329+
: "🎙 采集中"}
330+
</span>
331+
)}
332+
</>
333+
}
334+
/>
365335

366336
{/* 页面区(TD-004:保留挂载 + display 切换——页面切换不重挂载,
367337
避免 ClassroomPage 每次进入重复窗口枚举 100-500ms 停顿;状态与事件监听保留) */}
@@ -475,6 +445,42 @@ function MainShell() {
475445
<SettingsPage active={page === "settings"} />
476446
</PageSlot>
477447
</main>
448+
{/* v0.8.0 F2:AI 任务完成通知(全局 toast——跨页面可见)。
449+
批 3 T7(控制方裁决 A3):**从 `<nav>` 行内搬到 fixed 覆盖层** —— 它曾是 1024 溢出的唯一主因
450+
(单项 373.75 px = 视口的 36.5%;含它 1375.74、剔除它 997.99),规格 §6.1 的顶栏清单里
451+
也没有它。落在 `MainShell` 最外层(**不是** nav 的子节点)⇒ 不参与顶栏宽度分配,
452+
1024 档的宽度验收因此**不必**把 toast 剔出去(剔除读数求通过是本批禁止的自我欺骗)。
453+
跨页面可见性不变:它挂在导航壳上,与页面切换无关。
454+
Why 自足内联、不 import L1 的 `Toast` 原语:批 3 非目标 2 明令不许 import `ui/primitives`
455+
(会把整层 CSS 与 `motion.css` 拉进首屏)。批 4 迁移原语时整条交给 `Toast`。
456+
文案与三档配色逐字沿用迁移前(零观感变化);唯一差别是**不再 ellipsis 单行裁切** ——
457+
旧裁切只为在 56px 行里抢宽度,脱离行内后换行更可读。
458+
⚠️ 层级(登记给批 4):`zIndex("toast")` = 500,而对话面板今日仍是裸数字 900
459+
(zIndex.guard 冻结名单里的一项)⇒ 面板展开时 toast 可能被它盖住;批 4 把面板迁到
460+
`zIndex("panel")`(=100)后按六档标尺自然消解。本任务不擅自改面板层级。 */}
461+
{aiToast && (
462+
<div
463+
data-testid="ai-toast"
464+
role="status"
465+
style={{
466+
position: "fixed",
467+
// 顶栏之下 8px:纵向位置消费 --ed-nav-h(M1 的单一真源),不写死 56
468+
top: "calc(var(--ed-nav-h) + 8px)",
469+
right: 16,
470+
zIndex: zIndex("toast"),
471+
maxWidth: 420,
472+
fontSize: 12,
473+
fontWeight: 500,
474+
borderRadius: 12,
475+
padding: "6px 12px",
476+
color: aiToast.kind === "ok" ? "#047857" : "#b91c1c",
477+
background: aiToast.kind === "ok" ? "#ecfdf5" : "#fef2f2",
478+
border: `1px solid ${aiToast.kind === "ok" ? "#a7f3d0" : "#fecaca"}`,
479+
}}
480+
>
481+
{aiToast.text}
482+
</div>
483+
)}
478484
{/* REQ-274:全局 AI 对话面板(常驻挂载——开合仅切 display,选中态/后台任务保活)。
479485
批 2:闸门只加在**首开之前** —— dockMounted 一旦为真永不复位,此后 open/close
480486
仍是纯 display 切换(保活语义逐字保留);独立 Suspense(fallback=null) 与 PageSlot

‎app/src/shell/TopBar.css‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* TopBar.css — A′ 顶栏(规格 §6.1)。批 3 只做静态布局与两档溢出。
3+
*
4+
* Why 需要这个文件(现网 App.tsx 的顶栏是内联 style):两档溢出是**媒体查询**的活,内联 style
5+
* 表达不了。动效(墨渍 hover / 按下微陷 / 焦点环落纸)属批 6 ⇒ 本文件**不得**出现
6+
* transition / animation / @keyframes(TopBar.test.tsx 的静态守卫逐条扫)。
7+
*
8+
* 高度消费 --ed-nav-h(批 3 T2 的 token 真源);三处档位数值消费 `shell/breakpoints.ts`
9+
* (nav / navFull / navActionsFull),但 CSS 媒体查询**读不到 TS 常量** ⇒ 两侧必须同值,
10+
* 由 TopBar.test.tsx 的守卫断言钉住(1179 = navFull−1 · 1399 = navActionsFull−1 ·
11+
* 1023 = nav−1)。写错数字当场红。
12+
*
13+
* ⚠️ 顶栏项一律 `flex: 0 0 auto`(批 3 陷阱 #15):被压缩的 flex 项会让 `scrollWidth` 静默低估
14+
* —— 实测品牌项固有宽 141.8125 在 1024 下读成 83,足以把「溢出 352」读成「余量 26」。
15+
* 宁可溢出可见,也不要「压缩后的假余量」:一旦允许压缩,1024 无溢出这条验收就失去意义。
16+
*
17+
* ⚠️ 横向预算(控制方裁决 A7「R2」,实测口径见任务报告 §宽度表):规格 §6.1 的「≥1180 图标+文字」
18+
* 是按**纯文字**估的(其「约 1070px」漏算 9 个图标 ≈ +234 px)⇒ 满档真实需求 1353.8 px(含最宽
19+
* 采集徽标),**连默认窗 1280 都装不下**。1180 这个规格数字由既有测试冻结(`breakpoints.test.ts`)
20+
* ⇒ 只能让内容缩小去装它:Tab 内边距 16→12、Tab 间距 4→2、右簇间距 8→4、右簇文字压到 1399 以下。
21+
* 结果是满档 1180 余量 +19.6、默认窗 1280 余量 +119.6(最坏徽标态),1024 余量 +291.89。
22+
*
23+
* ⚠️ 两组文字的显隐**必须用各自独立的类**(控制方 A7 明文):
24+
* · `.ed-topbar__tab-label`(8 个域 Tab)—— 只在 ≤1179 收起(规格 §6.1 第二级);
25+
* · `.ed-topbar__action-label`(⌘K / 对话面板 / ⚙ 三个右侧动作)—— 在 ≤1399 收起。
26+
* 混用同一条规则会连 Tab 文字一起藏掉(T7 报告附变异体实测)。
27+
*/
28+
.ed-topbar {
29+
height: var(--ed-nav-h, 56px);
30+
flex-shrink: 0;
31+
display: flex;
32+
align-items: center;
33+
gap: 4px;
34+
padding: 0 16px;
35+
border-bottom: 1px solid var(--ed-border, #eae7e0);
36+
background: var(--ed-bg-surface, #fff);
37+
box-sizing: border-box;
38+
}
39+
40+
.ed-topbar__brand {
41+
font-weight: 700;
42+
font-size: 15px;
43+
margin-right: 20px;
44+
white-space: nowrap;
45+
flex: 0 0 auto;
46+
}
47+
48+
.ed-topbar__tabs {
49+
display: flex;
50+
align-items: center;
51+
gap: 2px;
52+
/* 不许收缩(陷阱 #15 的同族):容器一旦可压,标签按钮会**溢出到右侧状态件底下**重叠,
53+
而 DOM 读数看不出(容器的 rect 仍然「装得下」)。宁可整行溢出可见,也不许静默重叠。 */
54+
flex: 0 0 auto;
55+
}
56+
57+
.ed-topbar__tab {
58+
display: inline-flex;
59+
align-items: center;
60+
gap: 6px;
61+
padding: 8px 12px;
62+
font-size: 13px;
63+
font-family: inherit;
64+
color: var(--ed-ink-2, #4b5563);
65+
border: none;
66+
border-bottom: 2px solid transparent;
67+
background: transparent;
68+
cursor: pointer;
69+
white-space: nowrap;
70+
flex: 0 0 auto;
71+
}
72+
73+
/* 选中态(当前页)与「按下/展开态」(如对话面板开合)共用同一档视觉:
74+
旧行内写法用边框 + 底色示意开合,收进本文件后统一成「墨色 + 下边框」这一档,少一套颜色。 */
75+
.ed-topbar__tab[aria-current="page"],
76+
.ed-topbar__tab[aria-pressed="true"] {
77+
font-weight: 600;
78+
color: var(--ed-link, #1f5fbf);
79+
border-bottom-color: var(--ed-link, #1f5fbf);
80+
}
81+
82+
/* 右侧动作簇(⌘K / 对话面板 / ⚙):**独立类**,不与域 Tab 共用显隐规则(见文件头) */
83+
.ed-topbar__action {
84+
display: inline-flex;
85+
align-items: center;
86+
gap: 6px;
87+
padding: 8px 12px;
88+
font-size: 13px;
89+
font-family: inherit;
90+
color: var(--ed-ink-2, #4b5563);
91+
border: none;
92+
border-bottom: 2px solid transparent;
93+
background: transparent;
94+
cursor: pointer;
95+
white-space: nowrap;
96+
flex: 0 0 auto;
97+
}
98+
99+
.ed-topbar__action[aria-current="page"],
100+
.ed-topbar__action[aria-pressed="true"] {
101+
font-weight: 600;
102+
color: var(--ed-link, #1f5fbf);
103+
border-bottom-color: var(--ed-link, #1f5fbf);
104+
}
105+
106+
.ed-topbar__right {
107+
margin-left: auto;
108+
display: flex;
109+
align-items: center;
110+
gap: 4px;
111+
flex-shrink: 0;
112+
}
113+
114+
/* ≥1400:右侧动作簇恢复文字(1180–1399 只显示图标 + 悬浮名 —— 实测满档装不下三份文字)。
115+
断点数值 = BREAKPOINTS.navActionsFull − 1 */
116+
@media (max-width: 1399px) {
117+
.ed-topbar__action-label {
118+
display: none;
119+
}
120+
.ed-topbar__action {
121+
padding: 8px;
122+
}
123+
}
124+
125+
/* 1024–1179:8 个域 Tab 仅图标 + 悬浮名(规格 §6.1 第二级「溢出两级」)。
126+
断点数值 = BREAKPOINTS.navFull − 1 */
127+
@media (max-width: 1179px) {
128+
.ed-topbar__tab-label {
129+
display: none;
130+
}
131+
.ed-topbar__tab {
132+
padding: 8px;
133+
}
134+
}
135+
136+
/* < 1024:规格 §6.1「由最小窗兜底」—— 最小窗是 1024(批 3 T4),故本档理论不可达。
137+
保留一条**防御性**规则:真到 1023 时宁可裁掉品牌,也不让右侧状态件(采集徽标)被推出视口。 */
138+
@media (max-width: 1023px) {
139+
.ed-topbar__brand {
140+
display: none;
141+
}
142+
}

0 commit comments

Comments
 (0)