diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab436568..9f860318 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,23 @@ name: Release Electron App on: push: tags: ['v*'] + # 兜底触发:semantic-release 若以内置 GITHUB_TOKEN 推 tag, + # GitHub 防递归机制不会派发 push 事件,导致本工作流从不运行、 + # Release 资产为空(latest.yml 404)。Release 发布事件可正常触发补跑。 + release: + types: [published] + # 手动兜底:在 UI 选择对应 tag 运行,用于补发历史版本资产 + workflow_dispatch: # 上传构建产物到 GitHub Release 需要写权限 permissions: contents: write +# 同一 tag 的多个触发源(push/release)串行执行,避免重复上传竞争 +concurrency: + group: release-electron-${{ github.ref_name }} + cancel-in-progress: false + jobs: lint-test: runs-on: ubuntu-latest @@ -41,7 +53,14 @@ jobs: - uses: actions/upload-artifact@v4 with: name: release-${{ matrix.os }} - path: client/release/*.{exe,dmg,AppImage} + # 注意:@actions/glob 不支持 {a,b} 花括号展开,必须逐行列出; + # latest*.yml 与 *.blockmap 是 electron-updater 自动更新的必需文件 + path: | + client/release/*.exe + client/release/*.dmg + client/release/*.AppImage + client/release/*.blockmap + client/release/latest*.yml release: needs: build diff --git a/client/electron/audioCapture.ts b/client/electron/audioCapture.ts index bc07da2b..b57a3128 100644 --- a/client/electron/audioCapture.ts +++ b/client/electron/audioCapture.ts @@ -18,6 +18,7 @@ import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron'; import { logger } from './logger'; +import { setPreferredDisplaySource } from './displayMediaHandler'; // ================================================================ // 类型定义 @@ -127,18 +128,15 @@ export class AudioCapture { * 开始音频捕获 * * 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源 - * 2. 方案A:若指定的是窗口源(window:xxx),优先尝试窗口级音频捕获, - * 同时解析一个屏幕环回源作为降级候选(Windows 上窗口源音频支持 - * 依赖 Chromium 版本,失败时渲染进程自动回退环回源) - * 3. 向渲染进程发送启动指令(含 sourceId + fallbackSourceId + 配置) - * 4. 渲染进程负责 getUserMedia 和音频切片 + * 2. 将期望源登记到 displayMedia handler(渲染进程 getDisplayMedia 时生效) + * 3. 向渲染进程发送启动指令(含 sourceId + 配置) + * 4. 渲染进程负责 getDisplayMedia 与音频切片 */ async start(win: BrowserWindow, sourceId?: string): Promise { if (this.capturing || this.disposed) return; // 解析音频源 let resolvedSourceId = sourceId ?? null; - let fallbackSourceId: string | null = null; if (!resolvedSourceId) { const sources = await listAudioSources(); if (sources.length === 0) { @@ -147,17 +145,12 @@ export class AudioCapture { } resolvedSourceId = sources[0].id; logger.info(`[AudioCapture] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`); - } else if (resolvedSourceId.startsWith('window:')) { - // 方案A:窗口源直采目标应用(如 B站客户端/浏览器)的音频, - // 预解析屏幕环回源作为降级候选,防御窗口级捕获不受支持的环境 - const sources = await listAudioSources(); - fallbackSourceId = sources[0]?.id ?? null; - logger.info( - `[AudioCapture] 方案A 窗口源音频捕获: ${resolvedSourceId}, ` + - `降级候选: ${fallbackSourceId ?? '无'}`, - ); } + // 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权 + // 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts) + setPreferredDisplaySource(resolvedSourceId); + this.capturing = true; this.boundWin = win; @@ -171,7 +164,6 @@ export class AudioCapture { if (!win.isDestroyed()) { win.webContents.send('audio_capture_do_start', { sourceId: resolvedSourceId, - fallbackSourceId, options: this.options, }); } diff --git a/client/electron/displayMediaHandler.ts b/client/electron/displayMediaHandler.ts new file mode 100644 index 00000000..8029acc9 --- /dev/null +++ b/client/electron/displayMediaHandler.ts @@ -0,0 +1,73 @@ +/** + * Electron 系统音频环回请求处理器(getDisplayMedia 桥) + * + * @ai-context: Windows 系统音频捕获的唯一可靠路径。Electron 30+ / Chromium 中, + * 渲染进程用 getUserMedia({ audio: { chromeMediaSource: 'desktop' } }) 单独请求 + * 音频轨已不返回真实音频——会拿到一条恒为数字零的静音轨(RMS 0.00,不抛错), + * 这是"监听 0 句 / 时间轴全静默"的根因。正确做法是主进程注册 + * setDisplayMediaRequestHandler 并在 callback 中返回 audio: 'loopback', + * 由渲染进程调用 getDisplayMedia({ video: true, audio: true }) 触发。 + * @ai-context: audio: 'loopback' 捕获系统输出混音(含本应用自身声音); + * 若需静音本地播放改用 'loopbackWithMute'。handler 全局唯一,重复注册会覆盖。 + * + * TODO(现场课程): 麦克风输入不走 displayMedia 路径,直接以 deviceId 调用 + * getUserMedia({ audio: { deviceId } }) 即可,无需本模块参与。 + */ + +import { session, desktopCapturer } from 'electron'; +import { logger } from './logger'; + +/** + * 期望捕获的视频源 ID(window:xxx / screen:xxx)。 + * 仅用于让 loopback 请求绑定到目标窗口所在屏幕;音频始终是系统混音。 + */ +let preferredSourceId: string | null = null; + +/** 设置本次采集期望的源 ID(由 AudioCapture.start 调用),null 表示用首个屏幕源 */ +export function setPreferredDisplaySource(sourceId: string | null): void { + preferredSourceId = sourceId; +} + +/** + * 注册 displayMedia 请求处理器(须在 app.whenReady 后调用一次) + * + * 渲染进程调用 navigator.mediaDevices.getDisplayMedia() 时进入此 handler, + * 我们直接指定源并附加 loopback 音频,跳过系统选择器(无需用户二次确认)。 + */ +export function registerDisplayMediaHandler(): void { + session.defaultSession.setDisplayMediaRequestHandler( + (_request, callback) => { + void (async () => { + try { + const sources = await desktopCapturer.getSources({ + types: ['screen', 'window'], + thumbnailSize: { width: 1, height: 1 }, + }); + if (sources.length === 0) { + logger.error('[DisplayMedia] 无可用捕获源,拒绝请求'); + // 空对象表示拒绝授权(Electron 约定) + callback({}); + return; + } + // 优先匹配期望源;未命中则退回首个屏幕源(屏幕源对 loopback 最稳定) + const matched = preferredSourceId + ? sources.find((s) => s.id === preferredSourceId) + : undefined; + const screenSource = sources.find((s) => s.id.startsWith('screen:')); + const chosen = matched ?? screenSource ?? sources[0]; + + logger.info( + `[DisplayMedia] 授予捕获: source=${chosen.name} (${chosen.id}), audio=loopback`, + ); + callback({ video: chosen, audio: 'loopback' }); + } catch (err) { + logger.error('[DisplayMedia] 处理捕获请求失败:', err); + callback({}); + } + })(); + }, + // 使用应用内指定源而非 Windows 系统选择器,保持无感采集体验 + { useSystemPicker: false }, + ); + logger.info('[DisplayMedia] setDisplayMediaRequestHandler 已注册(audio: loopback)'); +} diff --git a/client/electron/main.ts b/client/electron/main.ts index 7c5f539f..c7d19681 100644 --- a/client/electron/main.ts +++ b/client/electron/main.ts @@ -33,6 +33,7 @@ import { resolveDbPath } from './db/storageConfig.js'; import { registerMigrationHandlers } from './db/migration.js'; import { loadEnvironment } from './envLoader.js'; import { installCspPolicy } from './cspPolicy.js'; +import { registerDisplayMediaHandler } from './displayMediaHandler.js'; import { registerDbIpcHandlers } from './db/dbIpcHandlers.js'; import { registerStorageIpcHandlers } from './storageIpcHandlers.js'; import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js'; @@ -123,6 +124,10 @@ if (!gotTheLock) { // SEC-005: CSP 安全策略注入(详见 cspPolicy.ts) installCspPolicy(isDevMode()); + // 系统音频环回捕获授权(详见 displayMediaHandler.ts) + // 必须在渲染进程发起 getDisplayMedia 之前注册,否则请求会被默认拒绝 + registerDisplayMediaHandler(); + // 加载持久化的 AI 网关地址(在注册 handler 之前,确保 handler 可用正确的 URL) await loadPersistedGatewayUrl(); logger.info(`[AI] Gateway URL resolved: ${gatewayUrl()}`); diff --git a/client/electron/updater.ts b/client/electron/updater.ts index 3364f958..ed9763f1 100644 --- a/client/electron/updater.ts +++ b/client/electron/updater.ts @@ -98,6 +98,23 @@ function checkForUpdatesWithTimeout(): Promise { }); } +/** + * 将 electron-updater 的原始错误映射为用户可读的提示。 + * 原始 message 可能包含整段 HTTP headers / 堆栈(如 latest.yml 404), + * 不应直接透传到 UI;完整信息仍会经 logger 落盘供排查。 + */ +function toFriendlyUpdateError(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err); + if (/latest.*\.yml/i.test(raw) || /404/.test(raw)) { + return '更新服务暂时不可用(新版本文件尚未发布完成),请稍后再试'; + } + if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|net::ERR/i.test(raw)) { + return '网络连接异常,请检查网络后重试'; + } + // 兜底:截断超长原始信息,避免刷屏 + return raw.length > 100 ? `${raw.slice(0, 100)}…` : raw; +} + export function initAutoUpdater(mainWindow: BrowserWindow | null): void { // 开发模式下禁用自动更新 if (!app.isPackaged) { @@ -183,7 +200,7 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void { sendToRenderer(mainWindow, 'update-status', { status: 'error', - message: error.message, + message: toFriendlyUpdateError(error), }); }); @@ -210,11 +227,14 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void { .then(() => { saveLastCheckTimestamp(); }) .catch((err: unknown) => { logger.error('[AutoUpdater] Initial check failed', err); + // 失败同样写入节流时间戳:错误已提示过一次, + // 避免服务端问题未修复期间每次启动都重复弹出同一错误 + saveLastCheckTimestamp(); // 断网导致的失败不通知前端 if (!net.isOnline()) return; sendToRenderer(mainWindow, 'update-status', { status: 'error', - message: err instanceof Error ? err.message : String(err), + message: toFriendlyUpdateError(err), }); }); }, 10_000); @@ -233,7 +253,7 @@ export function checkForUpdate(): void { sendToRenderer( BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) ?? null, 'update-status', - { status: 'error', message: err instanceof Error ? err.message : String(err) }, + { status: 'error', message: toFriendlyUpdateError(err) }, ); }); } diff --git a/client/public/sounds/_backup_original/achievement_unlocked.wav b/client/public/sounds/_backup_original/achievement_unlocked.wav new file mode 100644 index 00000000..629bd0f6 Binary files /dev/null and b/client/public/sounds/_backup_original/achievement_unlocked.wav differ diff --git a/client/public/sounds/_backup_original/ai_analysis_done.wav b/client/public/sounds/_backup_original/ai_analysis_done.wav new file mode 100644 index 00000000..b8ab08a7 Binary files /dev/null and b/client/public/sounds/_backup_original/ai_analysis_done.wav differ diff --git a/client/public/sounds/_backup_original/capture_start.wav b/client/public/sounds/_backup_original/capture_start.wav new file mode 100644 index 00000000..e5341c43 Binary files /dev/null and b/client/public/sounds/_backup_original/capture_start.wav differ diff --git a/client/public/sounds/_backup_original/capture_stop.wav b/client/public/sounds/_backup_original/capture_stop.wav new file mode 100644 index 00000000..568840a5 Binary files /dev/null and b/client/public/sounds/_backup_original/capture_stop.wav differ diff --git a/client/public/sounds/_backup_original/card_flip.wav b/client/public/sounds/_backup_original/card_flip.wav new file mode 100644 index 00000000..a8b312f6 Binary files /dev/null and b/client/public/sounds/_backup_original/card_flip.wav differ diff --git a/client/public/sounds/_backup_original/daily_checkin.wav b/client/public/sounds/_backup_original/daily_checkin.wav new file mode 100644 index 00000000..7883bb3c Binary files /dev/null and b/client/public/sounds/_backup_original/daily_checkin.wav differ diff --git a/client/public/sounds/_backup_original/data_cleared.wav b/client/public/sounds/_backup_original/data_cleared.wav new file mode 100644 index 00000000..75ec9c0f Binary files /dev/null and b/client/public/sounds/_backup_original/data_cleared.wav differ diff --git a/client/public/sounds/_backup_original/data_export.wav b/client/public/sounds/_backup_original/data_export.wav new file mode 100644 index 00000000..73a5b3c6 Binary files /dev/null and b/client/public/sounds/_backup_original/data_export.wav differ diff --git a/client/public/sounds/_backup_original/data_import.wav b/client/public/sounds/_backup_original/data_import.wav new file mode 100644 index 00000000..281040dc Binary files /dev/null and b/client/public/sounds/_backup_original/data_import.wav differ diff --git a/client/public/sounds/_backup_original/deck_complete.wav b/client/public/sounds/_backup_original/deck_complete.wav new file mode 100644 index 00000000..194b22c6 Binary files /dev/null and b/client/public/sounds/_backup_original/deck_complete.wav differ diff --git a/client/public/sounds/_backup_original/feedback_delete.wav b/client/public/sounds/_backup_original/feedback_delete.wav new file mode 100644 index 00000000..76854735 Binary files /dev/null and b/client/public/sounds/_backup_original/feedback_delete.wav differ diff --git a/client/public/sounds/_backup_original/feedback_error.wav b/client/public/sounds/_backup_original/feedback_error.wav new file mode 100644 index 00000000..eab51ac2 Binary files /dev/null and b/client/public/sounds/_backup_original/feedback_error.wav differ diff --git a/client/public/sounds/_backup_original/feedback_success.wav b/client/public/sounds/_backup_original/feedback_success.wav new file mode 100644 index 00000000..8390fc32 Binary files /dev/null and b/client/public/sounds/_backup_original/feedback_success.wav differ diff --git a/client/public/sounds/_backup_original/feedback_warning.wav b/client/public/sounds/_backup_original/feedback_warning.wav new file mode 100644 index 00000000..f5fb3eb3 Binary files /dev/null and b/client/public/sounds/_backup_original/feedback_warning.wav differ diff --git a/client/public/sounds/_backup_original/feynman_complete.wav b/client/public/sounds/_backup_original/feynman_complete.wav new file mode 100644 index 00000000..1286aaf8 Binary files /dev/null and b/client/public/sounds/_backup_original/feynman_complete.wav differ diff --git a/client/public/sounds/_backup_original/feynman_record_start.wav b/client/public/sounds/_backup_original/feynman_record_start.wav new file mode 100644 index 00000000..70d1a2a0 Binary files /dev/null and b/client/public/sounds/_backup_original/feynman_record_start.wav differ diff --git a/client/public/sounds/_backup_original/feynman_record_stop.wav b/client/public/sounds/_backup_original/feynman_record_stop.wav new file mode 100644 index 00000000..440343cb Binary files /dev/null and b/client/public/sounds/_backup_original/feynman_record_stop.wav differ diff --git a/client/public/sounds/_backup_original/feynman_weak_point.wav b/client/public/sounds/_backup_original/feynman_weak_point.wav new file mode 100644 index 00000000..4010b0da Binary files /dev/null and b/client/public/sounds/_backup_original/feynman_weak_point.wav differ diff --git a/client/public/sounds/_backup_original/note_autosave.wav b/client/public/sounds/_backup_original/note_autosave.wav new file mode 100644 index 00000000..5b7f5104 Binary files /dev/null and b/client/public/sounds/_backup_original/note_autosave.wav differ diff --git a/client/public/sounds/_backup_original/note_manual_save.wav b/client/public/sounds/_backup_original/note_manual_save.wav new file mode 100644 index 00000000..68ddf928 Binary files /dev/null and b/client/public/sounds/_backup_original/note_manual_save.wav differ diff --git a/client/public/sounds/_backup_original/pomodoro_5min_warning.wav b/client/public/sounds/_backup_original/pomodoro_5min_warning.wav new file mode 100644 index 00000000..09ac3f7e Binary files /dev/null and b/client/public/sounds/_backup_original/pomodoro_5min_warning.wav differ diff --git a/client/public/sounds/_backup_original/pomodoro_break_end.wav b/client/public/sounds/_backup_original/pomodoro_break_end.wav new file mode 100644 index 00000000..b87998e8 Binary files /dev/null and b/client/public/sounds/_backup_original/pomodoro_break_end.wav differ diff --git a/client/public/sounds/_backup_original/pomodoro_complete.wav b/client/public/sounds/_backup_original/pomodoro_complete.wav new file mode 100644 index 00000000..6230db2b Binary files /dev/null and b/client/public/sounds/_backup_original/pomodoro_complete.wav differ diff --git a/client/public/sounds/_backup_original/pomodoro_pause.wav b/client/public/sounds/_backup_original/pomodoro_pause.wav new file mode 100644 index 00000000..6cb9cfcd Binary files /dev/null and b/client/public/sounds/_backup_original/pomodoro_pause.wav differ diff --git a/client/public/sounds/_backup_original/pomodoro_start.wav b/client/public/sounds/_backup_original/pomodoro_start.wav new file mode 100644 index 00000000..6b861f74 Binary files /dev/null and b/client/public/sounds/_backup_original/pomodoro_start.wav differ diff --git a/client/public/sounds/_backup_original/pomodoro_tick_final.wav b/client/public/sounds/_backup_original/pomodoro_tick_final.wav new file mode 100644 index 00000000..1bec4e31 Binary files /dev/null and b/client/public/sounds/_backup_original/pomodoro_tick_final.wav differ diff --git a/client/public/sounds/_backup_original/pomodoro_work_complete.wav b/client/public/sounds/_backup_original/pomodoro_work_complete.wav new file mode 100644 index 00000000..4bdbdde4 Binary files /dev/null and b/client/public/sounds/_backup_original/pomodoro_work_complete.wav differ diff --git a/client/public/sounds/_backup_original/rate_forgot.wav b/client/public/sounds/_backup_original/rate_forgot.wav new file mode 100644 index 00000000..4525740c Binary files /dev/null and b/client/public/sounds/_backup_original/rate_forgot.wav differ diff --git a/client/public/sounds/_backup_original/rate_fuzzy.wav b/client/public/sounds/_backup_original/rate_fuzzy.wav new file mode 100644 index 00000000..f9037573 Binary files /dev/null and b/client/public/sounds/_backup_original/rate_fuzzy.wav differ diff --git a/client/public/sounds/_backup_original/rate_remember.wav b/client/public/sounds/_backup_original/rate_remember.wav new file mode 100644 index 00000000..dcdeae3d Binary files /dev/null and b/client/public/sounds/_backup_original/rate_remember.wav differ diff --git a/client/public/sounds/_backup_original/sync_complete.wav b/client/public/sounds/_backup_original/sync_complete.wav new file mode 100644 index 00000000..67e3dc96 Binary files /dev/null and b/client/public/sounds/_backup_original/sync_complete.wav differ diff --git a/client/public/sounds/_backup_original/ui_click.wav b/client/public/sounds/_backup_original/ui_click.wav new file mode 100644 index 00000000..739e26a1 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_click.wav differ diff --git a/client/public/sounds/_backup_original/ui_drag_start.wav b/client/public/sounds/_backup_original/ui_drag_start.wav new file mode 100644 index 00000000..0ae2fb32 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_drag_start.wav differ diff --git a/client/public/sounds/_backup_original/ui_drop.wav b/client/public/sounds/_backup_original/ui_drop.wav new file mode 100644 index 00000000..fd8da7f8 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_drop.wav differ diff --git a/client/public/sounds/_backup_original/ui_hover_3d.wav b/client/public/sounds/_backup_original/ui_hover_3d.wav new file mode 100644 index 00000000..25dbb132 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_hover_3d.wav differ diff --git a/client/public/sounds/_backup_original/ui_modal_close.wav b/client/public/sounds/_backup_original/ui_modal_close.wav new file mode 100644 index 00000000..8ebecb61 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_modal_close.wav differ diff --git a/client/public/sounds/_backup_original/ui_modal_open.wav b/client/public/sounds/_backup_original/ui_modal_open.wav new file mode 100644 index 00000000..46ea8236 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_modal_open.wav differ diff --git a/client/public/sounds/_backup_original/ui_module_enter.wav b/client/public/sounds/_backup_original/ui_module_enter.wav new file mode 100644 index 00000000..8d180bb1 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_module_enter.wav differ diff --git a/client/public/sounds/_backup_original/ui_nav_switch.wav b/client/public/sounds/_backup_original/ui_nav_switch.wav new file mode 100644 index 00000000..7cef3883 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_nav_switch.wav differ diff --git a/client/public/sounds/_backup_original/ui_tab_switch.wav b/client/public/sounds/_backup_original/ui_tab_switch.wav new file mode 100644 index 00000000..a9fe991f Binary files /dev/null and b/client/public/sounds/_backup_original/ui_tab_switch.wav differ diff --git a/client/public/sounds/_backup_original/ui_toggle_off.wav b/client/public/sounds/_backup_original/ui_toggle_off.wav new file mode 100644 index 00000000..e875d6ad Binary files /dev/null and b/client/public/sounds/_backup_original/ui_toggle_off.wav differ diff --git a/client/public/sounds/_backup_original/ui_toggle_on.wav b/client/public/sounds/_backup_original/ui_toggle_on.wav new file mode 100644 index 00000000..9d65ef59 Binary files /dev/null and b/client/public/sounds/_backup_original/ui_toggle_on.wav differ diff --git a/client/public/sounds/ritual_breath_cycle.wav b/client/public/sounds/ritual_breath_cycle.wav new file mode 100644 index 00000000..26e34f3a Binary files /dev/null and b/client/public/sounds/ritual_breath_cycle.wav differ diff --git a/client/public/sounds/ritual_breath_exhale.wav b/client/public/sounds/ritual_breath_exhale.wav new file mode 100644 index 00000000..ef804b14 Binary files /dev/null and b/client/public/sounds/ritual_breath_exhale.wav differ diff --git a/client/public/sounds/ritual_breath_hold.wav b/client/public/sounds/ritual_breath_hold.wav new file mode 100644 index 00000000..2aecb95b Binary files /dev/null and b/client/public/sounds/ritual_breath_hold.wav differ diff --git a/client/public/sounds/ritual_breath_inhale.wav b/client/public/sounds/ritual_breath_inhale.wav new file mode 100644 index 00000000..a7087fdd Binary files /dev/null and b/client/public/sounds/ritual_breath_inhale.wav differ diff --git a/client/src/components/ui/UpdateNotification.tsx b/client/src/components/ui/UpdateNotification.tsx index af5b5a22..6570d806 100644 --- a/client/src/components/ui/UpdateNotification.tsx +++ b/client/src/components/ui/UpdateNotification.tsx @@ -9,7 +9,7 @@ */ import { useState, useEffect } from 'react'; import DOMPurify from 'dompurify'; -import { Download, RefreshCw, X, ChevronDown } from 'lucide-react'; +import { Download, RefreshCw, X, ChevronDown, AlertCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; interface UpdateStatus { @@ -126,7 +126,22 @@ export default function UpdateNotification() { )} {updateStatus.status === 'error' && ( -

更新检查失败:{updateStatus.message}

+ <> +

+ + 更新检查失败 +

+

+ {updateStatus.message} +

+

不影响正常使用,可稍后在设置中手动检查更新。

+ + )} + ))} + + + {/* AI 识别开关 */} + + + ); +} diff --git a/client/src/features/classroom/components/IdleGuidePanel.tsx b/client/src/features/classroom/components/IdleGuidePanel.tsx new file mode 100644 index 00000000..5f365c2c --- /dev/null +++ b/client/src/features/classroom/components/IdleGuidePanel.tsx @@ -0,0 +1,77 @@ +/** + * IdleGuidePanel — 右侧空态"当前配置说明书"面板 + * Right-side idle guide: sonar visual + explanation cards of current selection. + * + * @ai-context: 采集未开始时的右侧空态。只展示"当前选中"的采集路径与模式各 + * 1 张说明卡(文案来自 constants.ts),左栏切换选项时通过 key 变化触发 + * page-fade-in 重播实现淡入切换;声呐波纹用 animate-ping 实现并遵循 + * prefers-reduced-motion(motion-reduce:animate-none)。 + * @ai-context: Idle-state panel showing ONLY the currently selected capture + * path & mode cards; children slot hosts the CourseInfoCard at the bottom. + */ +import type { ReactNode } from 'react'; +import { Radar } from 'lucide-react'; +import type { CaptureMode, CapturePath } from '@/lib/capture'; +import { PATH_OPTIONS, MODE_OPTIONS } from '../constants'; +import type { CaptureOptionMeta } from '../constants'; + +interface IdleGuidePanelProps { + capturePath: CapturePath; + mode: CaptureMode; + hasWindow: boolean; + /** 底部插槽:课程信息卡等启动前配置 */ + children?: ReactNode; +} + +/** 单张说明卡:图标 + 名称 + 简述徽标 + 详细说明 + 适合场景 */ +function GuideCard({ meta, kind }: { meta: CaptureOptionMeta; kind: string }) { + const Icon = meta.icon; + return ( +
+
+ + {meta.label} + {meta.brief} + {kind} +
+

{meta.detail}

+

适合:{meta.scene}

+
+ ); +} + +export function IdleGuidePanel({ capturePath, mode, hasWindow, children }: IdleGuidePanelProps) { + const pathMeta = PATH_OPTIONS.find((p) => p.value === capturePath) ?? PATH_OPTIONS[0]; + const modeMeta = MODE_OPTIONS.find((m) => m.value === mode) ?? MODE_OPTIONS[2]; + + return ( +
+ {/* 声呐待命视觉:双层波纹 + 中心雷达 */} +
+ + + + + +
+

声呐待命

+

+ {hasWindow ? '配置就绪,点击左侧「开始回声定位」启动扫描' : '先在左侧锁定目标窗口'} +

+ + {/* 当前配置说明卡:key 变化触发淡入切换 */} +
+ + +
+ + {children &&
{children}
} +
+ ); +} diff --git a/client/src/features/classroom/components/PathModeSelector.tsx b/client/src/features/classroom/components/PathModeSelector.tsx new file mode 100644 index 00000000..84fc1e3a --- /dev/null +++ b/client/src/features/classroom/components/PathModeSelector.tsx @@ -0,0 +1,74 @@ +/** + * PathModeSelector — 采集路径与采集模式选择器 + * Capture path (fine/smart/full_record) and mode (vision/audio/mixed) selector. + * + * @ai-context: 左栏配置态组件。为保证左栏一屏容纳,此处只保留选项按钮本身, + * 详细说明文案统一移至右侧空态说明卡(IdleGuidePanel),文案源自 constants.ts。 + * @ai-context: Left-rail config-stage component. Option details are rendered by + * the right-side IdleGuidePanel to keep the rail within one screen height. + */ +import { CheckCircle2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CaptureMode, CapturePath } from '@/lib/capture'; +import { PATH_OPTIONS, MODE_OPTIONS } from '../constants'; + +interface PathModeSelectorProps { + capturePath: CapturePath; + onPathChange: (path: CapturePath) => void; + mode: CaptureMode; + onModeChange: (mode: CaptureMode) => void; + disabled?: boolean; +} + +export function PathModeSelector({ capturePath, onPathChange, mode, onModeChange, disabled }: PathModeSelectorProps) { + return ( +
+ {/* 采集路径 */} +
+ 采集路径 +
+ {PATH_OPTIONS.map(({ value, label, icon: PathIcon, brief }) => ( + + ))} +
+
+ + {/* 采集模式 */} +
+ 采集模式 +
+ {MODE_OPTIONS.map(({ value, label, icon: ModeIcon }) => ( + + ))} +
+
+
+ ); +} diff --git a/client/src/features/classroom/components/SegmentList.tsx b/client/src/features/classroom/components/SegmentList.tsx new file mode 100644 index 00000000..01b6fa3e --- /dev/null +++ b/client/src/features/classroom/components/SegmentList.tsx @@ -0,0 +1,75 @@ +/** + * SegmentList — 精细路径提取结果列表 + * Extracted segment list for the fine capture path. + * + * @ai-context: 右侧内容区组件(fine 路径)。从 ClassroomPage 平移拆出, + * 支持多选/批量插入(当前落地为复制剪贴板),新片段到达时自动滚动到底部。 + * @ai-context: Right-pane component for the fine path, split out of + * ClassroomPage; auto-scrolls to bottom when new segments arrive. + */ +import { useRef, useEffect } from 'react'; +import { Eye, Plus, ListPlus, CheckCircle2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ExtractedSegment } from '@/lib/capture'; + +interface SegmentListProps { + segments: ExtractedSegment[]; + selectedIds: Set; + onToggleSelect: (id: string) => void; + onInsertSelected: () => void; + onInsertAll: () => void; +} + +export function SegmentList({ segments, selectedIds, onToggleSelect, onInsertSelected, onInsertAll }: SegmentListProps) { + const scrollRef = useRef(null); + useEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, [segments.length]); + + return ( +
+
+ 提取结果 ({segments.length}) +
+ + +
+
+
+ {segments.length === 0 && ( +
+ +

采集进行中,提取结果将在此显示

+
+ )} + {segments.map((seg) => { + const isSelected = selectedIds.has(seg.id); + const sourceLabel = seg.source === 'vision' ? '视觉' : seg.source === 'audio' ? '音频' : 'UI'; + const sourceColor = seg.source === 'vision' + ? 'bg-accent-500/10 text-accent-600' + : seg.source === 'audio' ? 'bg-emerald-500/10 text-emerald-600' : 'bg-amber-500/10 text-amber-600'; + return ( +
onToggleSelect(seg.id)} + className={cn('group p-3 rounded-kb-md cursor-pointer transition-all border border-transparent', + isSelected ? 'bg-brand-50/50 border-brand-200/50' : 'hover:bg-bg-tertiary/50')}> +
+ {sourceLabel} + {new Date(seg.timestamp).toLocaleTimeString()} + {isSelected && } +
+

{seg.text}

+
+ ); + })} +
+
+ ); +} diff --git a/client/src/features/classroom/components/SettingsCollapse.tsx b/client/src/features/classroom/components/SettingsCollapse.tsx new file mode 100644 index 00000000..f601313f --- /dev/null +++ b/client/src/features/classroom/components/SettingsCollapse.tsx @@ -0,0 +1,68 @@ +/** + * SettingsCollapse — 采集设置折叠区 + * Collapsible capture settings (screenshot interval + recognition language). + * + * @ai-context: 左栏配置态组件,默认收起以保证左栏一屏容纳;收起时标题行 + * 显示当前配置摘要(如 "5s · 中文"),展开后提供滑块与语言切换。 + * @ai-context: Left-rail config-stage component, collapsed by default to keep + * the rail within one screen; header shows a summary of current values. + */ +import { useState } from 'react'; +import { Settings2, ChevronDown, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CaptureSidebarConfig } from '@/lib/capture'; +import { LANGUAGE_OPTIONS } from '../constants'; + +interface SettingsCollapseProps { + config: CaptureSidebarConfig; + onChange: (patch: Partial) => void; +} + +export function SettingsCollapse({ config, onChange }: SettingsCollapseProps) { + const [open, setOpen] = useState(false); + const intervalSec = config.screenshotInterval / 1000; + const languageLabel = LANGUAGE_OPTIONS.find((l) => l.value === config.language)?.label ?? ''; + const Chevron = open ? ChevronDown : ChevronRight; + + return ( +
+ {/* 折叠标题行:收起时展示配置摘要 */} + + + {open && ( +
+
+ + onChange({ screenshotInterval: Number(e.target.value) * 1000 })} + className="w-full accent-brand-600" /> +
+
+ +
+ {LANGUAGE_OPTIONS.map(({ value, label }) => ( + + ))} +
+
+
+ )} +
+ ); +} diff --git a/client/src/features/classroom/components/SonarControls.tsx b/client/src/features/classroom/components/SonarControls.tsx new file mode 100644 index 00000000..cc5cfac9 --- /dev/null +++ b/client/src/features/classroom/components/SonarControls.tsx @@ -0,0 +1,123 @@ +/** + * SonarControls — 运行态声呐仪表盘与控制面板 + * Running-stage sonar dashboard: elapsed time, frame/segment stats, controls. + * + * @ai-context: 左栏运行态组件,采集开始后替换整个配置区(双阶段左栏设计)。 + * 组件挂载即开始计时(页面仅在运行态渲染本组件,卸载自动归零),status + * 为 capturing 时每秒累加,暂停时停表。继续采集复用 handleStart。 + * @ai-context: Left-rail running-stage component that replaces the config area + * while a session is active. Timer accumulates only while status==='capturing'. + */ +import { useState, useEffect } from 'react'; +import { Play, Pause, Square, Star, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SessionStatus, CaptureMode } from '@/lib/capture'; +import { STATUS_CONFIG } from '../constants'; + +interface SonarControlsProps { + status: SessionStatus; + stats: { frames: number; extracted: number }; + mode: CaptureMode; + audioHealthy: boolean; + onPause: () => void; + onResume: () => void; + onStop: () => void; + onBookmark: () => void; +} + +/** 秒数格式化为 mm:ss 或 h:mm:ss(纯函数,无副作用) */ +function formatElapsed(totalSeconds: number): string { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + const mm = String(m).padStart(2, '0'); + const ss = String(s).padStart(2, '0'); + return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; +} + +export function SonarControls({ status, stats, mode, audioHealthy, onPause, onResume, onStop, onBookmark }: SonarControlsProps) { + const [elapsed, setElapsed] = useState(0); + const statusCfg = STATUS_CONFIG[status]; + const StatusIcon = statusCfg.icon; + const hasAudio = mode === 'audio' || mode === 'mixed'; + + // 仅采集中走表;暂停/处理中停表,组件卸载(会话结束)自动归零 + useEffect(() => { + if (status !== 'capturing') return; + const timer = setInterval(() => setElapsed((sec) => sec + 1), 1000); + return () => clearInterval(timer); + }, [status]); + + return ( +
+ {/* 状态行 */} +
+
+ + {statusCfg.label} +
+ {hasAudio && status === 'capturing' && ( +
+ + + {audioHealthy ? '音频正常' : '音频异常'} + +
+ )} +
+ + {/* 采集时长 */} +
+

采集时长

+

{formatElapsed(elapsed)}

+
+ + {/* 帧 / 段 统计 */} +
+
+

{stats.frames}

+

捕获帧

+
+
+

{stats.extracted}

+

提取段

+
+
+ + {status === 'processing' && ( +
+ + 正在处理采集数据... +
+ )} + + {/* 控制按钮:贴底放置 */} +
+ {status === 'capturing' && ( + + )} +
+ {status === 'capturing' && ( + + )} + {status === 'paused' && ( + + )} + +
+
+
+ ); +} diff --git a/client/src/features/classroom/components/WindowSelectCard.tsx b/client/src/features/classroom/components/WindowSelectCard.tsx new file mode 100644 index 00000000..2f99417e --- /dev/null +++ b/client/src/features/classroom/components/WindowSelectCard.tsx @@ -0,0 +1,167 @@ +/** + * WindowSelectCard — 目标窗口选择卡片 + * Target window selection card with a change-window popover. + * + * @ai-context: 左栏配置态组件。默认只展示"已选窗口"单卡片以压缩纵向空间, + * 点击卡片弹出浮层列表(含刷新)供更换;未选中时整卡为虚线引导按钮。 + * 浮层通过 document mousedown 监听点击外部自动关闭。 + * @ai-context: 浮层内两级列表——"推荐窗口"(score>0,主进程 windowScorer + * 命中网课/浏览器/播放器关键词)默认展示;score=0 的窗口收进"显示全部窗口" + * 折叠区,兼作手动自选入口,保证任何可捕获窗口都能被选中。 + * @ai-context: Left-rail config-stage component. Popover shows a two-tier list: + * recommended (score>0) by default, with a "show all" toggle exposing every + * capturable window as the manual-pick fallback. + */ +import { useState, useRef, useEffect } from 'react'; +import { Monitor, RefreshCw, Star, ChevronDown, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { WindowInfo } from '@/lib/capture'; + +interface WindowSelectCardProps { + windows: WindowInfo[]; + selected: WindowInfo | null; + onSelect: (win: WindowInfo) => void; + onRefresh: () => void; + loading: boolean; + disabled?: boolean; +} + +/** 浮层内的单个窗口条目 */ +function WindowRow({ win, isSelected, onClick }: { win: WindowInfo; isSelected: boolean; onClick: () => void }) { + return ( + + ); +} + +export function WindowSelectCard({ windows, selected, onSelect, onRefresh, loading, disabled }: WindowSelectCardProps) { + const [open, setOpen] = useState(false); + const [showAll, setShowAll] = useState(false); + const rootRef = useRef(null); + + // 推荐窗口 = 主进程评分命中关键词;其余窗口收进"显示全部"折叠区 + const recommended = windows.filter((w) => (w.score ?? 0) > 0); + const others = windows.filter((w) => (w.score ?? 0) <= 0); + + // 点击浮层外部时自动关闭 + useEffect(() => { + if (!open) return; + const handleDocDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handleDocDown); + return () => document.removeEventListener('mousedown', handleDocDown); + }, [open]); + + const handleToggle = () => { + if (disabled) return; + const next = !open; + setOpen(next); + // 首次展开且列表为空时顺手触发刷新,减少一次点击 + if (next && windows.length === 0 && !loading) onRefresh(); + }; + + const handlePick = (win: WindowInfo) => { + onSelect(win); + setOpen(false); + }; + + return ( +
+ 目标窗口 + + {/* 已选窗口卡片 / 未选引导按钮 */} + + + {/* 窗口列表浮层:推荐 + 可展开的全部窗口 */} + {open && ( +
+
+ 推荐窗口 + +
+
+ {windows.length === 0 && !loading && ( +

未检测到可捕获窗口

+ )} + {windows.length > 0 && recommended.length === 0 && ( +

+ 未发现疑似网课/视频窗口 + 可从下方全部窗口中手动选择 +

+ )} + {recommended.map((win) => ( + handlePick(win)} /> + ))} + + {/* 全部窗口折叠区:手动自选未被识别为目标的窗口 */} + {others.length > 0 && ( + <> + + {showAll && others.map((win) => ( + handlePick(win)} /> + ))} + + )} +
+
+ )} +
+ ); +} diff --git a/client/src/features/classroom/constants.ts b/client/src/features/classroom/constants.ts new file mode 100644 index 00000000..0a89ebf4 --- /dev/null +++ b/client/src/features/classroom/constants.ts @@ -0,0 +1,107 @@ +/** + * 回声定位(课堂助手)UI 常量字典 + * Echo-location (classroom assistant) UI constant dictionaries. + * + * @ai-context: 采集路径/模式/状态/语言/学科的静态选项配置,纯数据无副作用。 + * 左栏选择器(PathModeSelector)与右侧空态说明卡(IdleGuidePanel)共用, + * 保证文案单一来源,修改文案只需改这里。 + * @ai-context: Static option configs shared by the left-rail selectors and the + * right-side idle guide cards; single source of truth for all option copy. + */ +import { + Eye, Mic, Layers, Clock, Pause, XCircle, Loader2, + Crosshair, Sparkles, Video, +} from 'lucide-react'; +import type { + CaptureMode, CapturePath, CaptureSidebarConfig, SessionStatus, +} from '@/lib/capture'; + +/** 采集选项元数据:label 用于按钮,brief/detail/scene 用于右侧说明卡 */ +export interface CaptureOptionMeta { + value: V; + label: string; + icon: typeof Eye; + /** 一句话简述(按钮副标题 / 说明卡徽标) */ + brief: string; + /** 详细说明(右侧空态说明卡正文) */ + detail: string; + /** 适合场景(右侧空态说明卡补充行) */ + scene: string; +} + +export const PATH_OPTIONS: CaptureOptionMeta[] = [ + // 智能路径置于首位并作为默认选项(资源占用低,适用面最广) + { + value: 'smart', + label: '智能', + icon: Sparkles, + brief: 'AI 关键帧', + detail: 'AI 自动检测画面变化,仅在幻灯片切换、板书出现等关键时刻截图,同时录制语音并智能分段,资源占用低。', + scene: '长时间网课、幻灯片授课,低资源占用后台采集。', + }, + { + value: 'fine', + label: '精细', + icon: Crosshair, + brief: '逐帧截图', + detail: '按固定间隔截取屏幕画面,逐帧进行 OCR/AI 识别,完整记录每一帧内容。', + scene: '板书密集、公式推导多、需要逐帧完整记录的课程。', + }, + { + value: 'full_record', + label: '录制', + icon: Video, + brief: '全程录像', + detail: '录制完整课堂视频(含音频),课后可通过 AI 一键生成结构化笔记。', + scene: '需要完整回放、课后深度复盘的重要课程。', + }, +]; + +export const MODE_OPTIONS: CaptureOptionMeta[] = [ + { + value: 'vision', + label: '视觉', + icon: Eye, + brief: '仅画面', + detail: '仅截取屏幕画面进行文字识别,不采集任何声音。', + scene: '无声视频、纯板书或 PPT 画面内容。', + }, + { + value: 'audio', + label: '音频', + icon: Mic, + brief: '仅声音', + detail: '仅录制声音进行语音转文字,不截取屏幕画面。', + scene: '播客、纯讲解且画面几乎不变的音频课。', + }, + { + value: 'mixed', + label: '混合', + icon: Layers, + brief: '画面+声音', + detail: '同时采集画面与声音,视觉与语音结果融合分析,信息覆盖最完整。', + scene: '大多数网课,画面与讲解并重(推荐)。', + }, +]; + +export const STATUS_CONFIG: Record = { + idle: { label: '空闲', color: 'text-text-tertiary', icon: Clock }, + capturing: { label: '采集中', color: 'text-semantic-error', icon: Loader2 }, + processing: { label: '处理中', color: 'text-brand-600', icon: Loader2 }, + paused: { label: '已暂停', color: 'text-semantic-warning', icon: Pause }, + error: { label: '错误', color: 'text-semantic-error', icon: XCircle }, +}; + +export const LANGUAGE_OPTIONS: { value: CaptureSidebarConfig['language']; label: string }[] = [ + { value: 'zh', label: '中文' }, + { value: 'en', label: 'English' }, + { value: 'mixed', label: '多语' }, +]; + +export const SUBJECT_OPTIONS = [ + { value: 'math', label: '数学' }, + { value: 'physics', label: '物理' }, + { value: 'cs', label: '计算机' }, + { value: 'english', label: '英语' }, + { value: 'other', label: '其他' }, +]; diff --git a/client/src/features/classroom/hooks/useAudioRecovery.ts b/client/src/features/classroom/hooks/useAudioRecovery.ts index 011ed7cd..72cc3208 100644 --- a/client/src/features/classroom/hooks/useAudioRecovery.ts +++ b/client/src/features/classroom/hooks/useAudioRecovery.ts @@ -1,15 +1,12 @@ /** - * 课堂音频自动恢复 hook(静音诊断 / 窗口源回退 / 设备变更重启) + * 课堂音频自动恢复 hook(静音诊断 / 设备变更重启) * - * @ai-context: 从 useClassroomAudio 拆出的独立恢复层。三条恢复路径: - * ①窗口源静音回退——Windows 上窗口源(window:xxx)getUserMedia 可能"成功但 - * 给出纯静音轨"(不抛错,异常降级不会触发),SilenceTracker 检出连续静音后 - * 自动 stop→start 以环回源重启(每会话仅回退一次); - * ②输出设备不匹配提示——已在环回源仍持续静音,说明视频声音输出到了 - * 非默认设备,提示用户核对默认输出设备; - * ③设备变更重启——默认输出设备切换后环回仍绑定旧设备,devicechange 时 - * 自动重启重新绑定。 - * @ai-context: 仅依赖 refs 的稳定回调,重启经 restartingRef 互斥防止并发。 + * @ai-context: 从 useClassroomAudio 拆出的独立恢复层。两条恢复路径: + * ①输出设备不匹配诊断——系统环回只录默认输出设备,视频声音若输出到其他 + * 设备(HDMI/蓝牙)会表现为"音频块正常但持续静音",检出后提示用户核对; + * ②设备变更重启——默认输出设备切换后环回仍绑定旧设备,devicechange 时 + * 自动 stop→start 重新绑定。 + * @ai-context: 仅依赖 refs 的稳定回调,重启经 restartingRef 互斥防并发。 */ import { useEffect, useRef, useCallback } from 'react'; import type { CaptureMode, SessionStatus } from '@/lib/capture'; @@ -26,7 +23,7 @@ const RESTART_CLEANUP_DELAY_MS = 500; interface UseAudioRecoveryOptions { status: SessionStatus; mode: CaptureMode; - /** 方案A:会话选中窗口的源 ID(回退环回后置空) */ + /** 会话选中窗口的源 ID,重启时沿用同一源 */ audioSourceId?: string | null; onNotify: (type: 'warning' | 'error', message: string) => void; } @@ -34,11 +31,8 @@ interface UseAudioRecoveryOptions { export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseAudioRecoveryOptions) { const notifyRef = useRef(onNotify); notifyRef.current = onNotify; - /** 当前生效的音频源(窗口源回退环回后变为 undefined) */ const effectiveSourceRef = useRef(audioSourceId ?? undefined); const silenceTrackerRef = useRef(new SilenceTracker()); - /** 本会话是否已执行过窗口源→环回回退 */ - const fellBackRef = useRef(false); const restartingRef = useRef(false); const audioEnabled = status === 'capturing' && (mode === 'audio' || mode === 'mixed'); @@ -47,7 +41,6 @@ export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseA useEffect(() => { if (status !== 'capturing') return; effectiveSourceRef.current = audioSourceId ?? undefined; - fellBackRef.current = false; silenceTrackerRef.current.reset(); // eslint-disable-next-line react-hooks/exhaustive-deps -- 仅会话开始时快照 audioSourceId }, [status]); @@ -73,31 +66,19 @@ export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseA } }, []); - // 静音诊断:窗口源先自动回退环回;已是环回仍静音则提示核对输出设备 + // 静音诊断:音频块正常但持续无声 → 提示核对系统默认输出设备 useEffect(() => { if (!audioEnabled || !window.electronAPI) return; const off = window.electronAPI.on('audio_capture_chunk', (...args: unknown[]) => { const chunk = args[0] as { audioBuffer: ArrayBuffer }; if (!silenceTrackerRef.current.push(computeChunkRms(chunk.audioBuffer))) return; - - const src = effectiveSourceRef.current; - if (src?.startsWith('window:') && !fellBackRef.current) { - // 方案A 漏洞补偿:窗口源"成功但静音"不抛错,只能靠静音检测触发回退 - fellBackRef.current = true; - effectiveSourceRef.current = undefined; - console.warn('[useAudioRecovery] 窗口音频源持续静音,自动回退系统环回'); - void restartCapture(undefined).then((ok) => { - if (ok) notifyRef.current('warning', '窗口音频源持续静音,已自动切换为系统环回捕获'); - }); - return; - } void getDefaultOutputDeviceLabel().then((label) => { notifyRef.current('warning', - `持续收到静音音频:请确认视频声音正在播放,且输出到系统默认设备${label ? `「${label}」` : ''}(音频捕获只能录到默认输出设备的声音)`); + `持续收到静音音频:请确认视频声音正在播放,且输出到系统默认设备${label ? `「${label}」` : ''}(系统音频捕获只能录到默认输出设备的声音)`); }); }); return off; - }, [audioEnabled, restartCapture]); + }, [audioEnabled]); // 设备变更自动重启:重新绑定新的默认输出设备 useEffect(() => { diff --git a/client/src/features/classroom/hooks/useClassroomAudio.ts b/client/src/features/classroom/hooks/useClassroomAudio.ts index 6d03aa25..429552a1 100644 --- a/client/src/features/classroom/hooks/useClassroomAudio.ts +++ b/client/src/features/classroom/hooks/useClassroomAudio.ts @@ -28,8 +28,6 @@ const CHUNK_GAP_TIMEOUT_MS = 10000; interface AudioStartPayload { sourceId: string; - /** 方案A 窗口源不可用时的环回降级候选(主进程解析) */ - fallbackSourceId?: string | null; options: { sampleRate: number; channels: number; chunkDurationMs: number }; } @@ -47,27 +45,35 @@ interface UseClassroomAudioOptions { } /** - * 以桌面源打开系统/窗口音频流。 - * 方案A 防御:窗口源(window:xxx)音频捕获在部分 Windows/Chromium 环境 - * 不受支持(NotFoundError/NotAllowedError),失败时自动回退环回降级候选。 + * 打开系统音频环回流。 + * + * 必须走 getDisplayMedia 且同时请求 video —— 主进程的 + * setDisplayMediaRequestHandler 会在授权时附加 audio: 'loopback', + * 这是 Windows 下拿到真实系统音频的唯一可靠路径。 + * 旧的 getUserMedia({ audio: { chromeMediaSource: 'desktop' } }) 在 + * Electron 30+ 会静默返回恒为数字零的静音轨(RMS 0.00),不可再用。 + * 拿到流后立即停掉视频轨,仅保留音频,避免屏幕捕获的额外开销。 */ -async function openDesktopAudioStream(payload: AudioStartPayload): Promise { - const request = (id: string) => navigator.mediaDevices.getUserMedia({ - audio: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: id, - } as MediaTrackConstraintSet, +async function openDesktopAudioStream(): Promise { + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, }); - try { - return await request(payload.sourceId); - } catch (err) { - if (!payload.fallbackSourceId) throw err; - console.warn( - `[useClassroomCapture] 窗口源音频捕获失败(${payload.sourceId}),回退系统环回:`, - err, - ); - return request(payload.fallbackSourceId); + const audioTracks = stream.getAudioTracks(); + if (audioTracks.length === 0) { + stream.getTracks().forEach((t) => t.stop()); + throw new Error('系统音频轨缺失:displayMedia 未返回 loopback 音频'); } + // 视频轨仅用于触发 loopback 授权,立即停止释放屏幕捕获资源 + stream.getVideoTracks().forEach((t) => { + t.stop(); + stream.removeTrack(t); + }); + console.info( + `[useClassroomCapture] 系统音频环回已获取: track="${audioTracks[0].label}", ` + + `enabled=${audioTracks[0].enabled}, muted=${audioTracks[0].muted}`, + ); + return stream; } export function useClassroomAudio({ captureManager, status, mode, onNotify }: UseClassroomAudioOptions) { @@ -152,8 +158,8 @@ export function useClassroomAudio({ captureManager, status, mode, onNotify }: Us const payload = args[0] as AudioStartPayload; (async () => { try { - // 方案A:优先窗口源直采,失败自动回退环回降级候选 - const stream = await openDesktopAudioStream(payload); + // 系统音频环回(主进程 handler 附加 audio: 'loopback') + const stream = await openDesktopAudioStream(); const audioCtx = new AudioContext({ sampleRate: payload.options.sampleRate }); // 关键修复:非用户手势调用栈中创建的 AudioContext 默认 suspended, // 必须显式 resume(),否则 onaudioprocess 永不触发(0 音频块)。 diff --git a/client/src/features/classroom/hooks/useClassroomCapture.ts b/client/src/features/classroom/hooks/useClassroomCapture.ts index 3fa13891..525e4302 100644 --- a/client/src/features/classroom/hooks/useClassroomCapture.ts +++ b/client/src/features/classroom/hooks/useClassroomCapture.ts @@ -34,7 +34,8 @@ export function useClassroomCapture() { const [status, setStatus] = useState('idle'); const [mode, setMode] = useState('mixed'); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [capturePath, setCapturePath] = useState('fine'); + // 默认智能路径:AI 关键帧 + 流式 ASR,资源占用低,适用面最广 + const [capturePath, setCapturePath] = useState('smart'); const [config, setConfig] = useState({ screenshotInterval: 5000, language: 'zh', diff --git a/client/src/features/classroom/pages/ClassroomPage.tsx b/client/src/features/classroom/pages/ClassroomPage.tsx index b6f7cd0b..923ee4ff 100644 --- a/client/src/features/classroom/pages/ClassroomPage.tsx +++ b/client/src/features/classroom/pages/ClassroomPage.tsx @@ -1,192 +1,51 @@ /** - * ClassroomPage — 课堂助手(回声定位)独立全页模块 - * 左侧窄栏:窗口选择 + 路径/模式 + 控制 + 设置 - * 右侧宽区:提取结果 / 智能时间轴 / 录制面板 / 分析预览 + * ClassroomPage — 课堂助手(回声定位)独立全页模块(装配层) + * Echo-location full-page module: assembly layer only. * - * @ai-context: classroom 功能模块页面:ClassroomPage。 + * 布局:双阶段左栏(配置态/运行态变形,一屏容纳不滚动)+ 右侧独立滚动内容区。 + * 配置态左栏:窗口卡片 → 路径/模式 → 设置折叠区 → 底部声呐启动按钮; + * 运行态左栏:SonarControls 仪表盘;右侧空态为当前配置说明书(IdleGuidePanel)。 + * + * @ai-context: classroom 功能模块页面。本文件仅做状态绑定与组件编排,业务 + * 逻辑全部在 useClassroomCapture 及其子 hooks 中,选项文案在 constants.ts。 + * @ai-context: Assembly-only page; capture orchestration lives in hooks and + * all option copy lives in constants.ts. Keep this file under 300 lines. */ -import { useRef, useEffect, useCallback, useState } from 'react'; -import { - Monitor, Play, Pause, Square, Eye, Mic, Layers, - Settings2, ChevronDown, ChevronRight, Plus, ListPlus, - Clock, CheckCircle2, XCircle, Loader2, Clapperboard, - Crosshair, Sparkles, Video, Info, Star, AlertTriangle, -} from 'lucide-react'; +import { useEffect, useCallback, useState } from 'react'; +import { Mic, CheckCircle2, XCircle, Loader2, Clapperboard, AlertTriangle, Radar } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useClassroomCapture } from '../hooks/useClassroomCapture'; import { LiveTranscript } from '../components/LiveTranscript'; import { NoteInsertDialog } from '../components/NoteInsertDialog'; +import { SegmentList } from '../components/SegmentList'; +import { WindowSelectCard } from '../components/WindowSelectCard'; +import { PathModeSelector } from '../components/PathModeSelector'; +import { SettingsCollapse } from '../components/SettingsCollapse'; +import { SonarControls } from '../components/SonarControls'; +import { IdleGuidePanel } from '../components/IdleGuidePanel'; +import { CourseInfoCard } from '../components/CourseInfoCard'; import { SmartCapturePanel } from '@/features/notes/components/SmartCapturePanel'; import { AnalysisPreview } from '@/features/notes/components/AnalysisPreview'; import { VideoRecordPanel } from '@/features/notes/components/VideoRecordPanel'; -import type { WindowInfo, ExtractedSegment, CaptureMode, SessionStatus, CaptureSidebarConfig, CourseMeta } from '@/lib/capture'; -import type { CapturePath } from '@/lib/capture'; - -// ================================================================ -// 子组件 -// ================================================================ - -const MODE_OPTIONS: { value: CaptureMode; label: string; icon: typeof Eye; desc: string }[] = [ - { value: 'vision', label: '视觉', icon: Eye, desc: '仅截取屏幕画面进行文字识别' }, - { value: 'audio', label: '音频', icon: Mic, desc: '仅录制声音进行语音转文字' }, - { value: 'mixed', label: '混合', icon: Layers, desc: '同时采集画面与声音,融合分析' }, -]; - -/** 采集路径配置:图标 + 简短描述 + 详细说明 */ -const PATH_OPTIONS: { value: CapturePath; label: string; icon: typeof Crosshair; brief: string; detail: string }[] = [ - { - value: 'fine', - label: '精细', - icon: Crosshair, - brief: '逐帧截图', - detail: '按固定间隔截取屏幕画面,逐帧进行 OCR/AI 识别。适合板书密集、需要完整记录每一帧内容的场景。', - }, - { - value: 'smart', - label: '智能', - icon: Sparkles, - brief: 'AI 关键帧', - detail: 'AI 自动检测画面变化,仅在幻灯片切换、板书出现等关键时刻截图,同时录制语音并智能分段。资源占用低,适合长时间课堂。', - }, - { - value: 'full_record', - label: '录制', - icon: Video, - brief: '全程录像', - detail: '录制完整课堂视频(含音频),课后可通过 AI 生成结构化笔记。适合需要完整回放或课后深度分析的场景。', - }, -]; - -const STATUS_CONFIG: Record = { - idle: { label: '空闲', color: 'text-text-tertiary', icon: Clock }, - capturing: { label: '采集中', color: 'text-semantic-error', icon: Loader2 }, - processing: { label: '处理中', color: 'text-brand-600', icon: Loader2 }, - paused: { label: '已暂停', color: 'text-semantic-warning', icon: Pause }, - error: { label: '错误', color: 'text-semantic-error', icon: XCircle }, -}; - -const LANGUAGE_OPTIONS: { value: CaptureSidebarConfig['language']; label: string }[] = [ - { value: 'zh', label: '中文' }, - { value: 'en', label: 'English' }, - { value: 'mixed', label: '多语' }, -]; - -function WindowSelector({ windows, selected, onSelect, onRefresh, loading }: { - windows: WindowInfo[]; selected: WindowInfo | null; - onSelect: (w: WindowInfo) => void; onRefresh: () => void; loading: boolean; -}) { - return ( -
-
- 目标窗口 - -
-
- {windows.length === 0 && !loading && ( -

未检测到可捕获窗口

- )} - {windows.map((win) => ( - - ))} -
-
- ); -} - -function SegmentList({ segments, selectedIds, onToggleSelect, onInsertSelected, onInsertAll }: { - segments: ExtractedSegment[]; selectedIds: Set; - onToggleSelect: (id: string) => void; onInsertSelected: () => void; onInsertAll: () => void; -}) { - const scrollRef = useRef(null); - useEffect(() => { - if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - }, [segments.length]); - - return ( -
-
- 提取结果 ({segments.length}) -
- - -
-
-
- {segments.length === 0 && ( -
- -

采集开始后提取结果将在此显示

-

选择目标窗口并点击"开始"

-
- )} - {segments.map((seg) => { - const isSelected = selectedIds.has(seg.id); - const sourceLabel = seg.source === 'vision' ? '视觉' : seg.source === 'audio' ? '音频' : 'UI'; - const sourceColor = seg.source === 'vision' - ? 'bg-accent-500/10 text-accent-600' - : seg.source === 'audio' ? 'bg-emerald-500/10 text-emerald-600' : 'bg-amber-500/10 text-amber-600'; - return ( -
onToggleSelect(seg.id)} - className={cn('group p-3 rounded-kb-md cursor-pointer transition-all border border-transparent', - isSelected ? 'bg-brand-50/50 border-brand-200/50' : 'hover:bg-bg-tertiary/50')}> -
- {sourceLabel} - {new Date(seg.timestamp).toLocaleTimeString()} - {isSelected && } -
-

{seg.text}

-
- ); - })} -
-
- ); -} - -// ================================================================ -// 主页面 -// ================================================================ export default function ClassroomPage() { const capture = useClassroomCapture(); - const statusCfg = STATUS_CONFIG[capture.status]; - const StatusIcon = statusCfg.icon; + + // ── 阶段判定:运行态(左栏变形为仪表盘)/ 配置态(右侧显示说明书) ── + const isRunning = capture.status === 'capturing' || capture.status === 'processing' || capture.status === 'paused'; + const hasSessionData = capture.segments.length > 0 + || (capture.smartBundle?.keyframes?.length ?? 0) > 0 + || (capture.smartBundle?.timeline?.length ?? 0) > 0 + || capture.liveTranscripts.length > 0 + || !!capture.videoFilePath; + const showIdleGuide = !isRunning && !hasSessionData + && !capture.isAnalyzing && !capture.analysisResult && !capture.analysisError; // ── 笔记插入弹窗状态 ── const [showNoteDialog, setShowNoteDialog] = useState(false); const [sessionSeq, setSessionSeq] = useState(1); - /** 点击“插入笔记”时打开弹窗,并计算当天采集序号 */ + /** 点击"插入笔记"时打开弹窗,并计算当天采集序号 */ const handleOpenNoteDialog = useCallback(async () => { const seq = await capture.getSessionSeq(); setSessionSeq(seq); @@ -220,8 +79,8 @@ export default function ClassroomPage() { return (
- {/* ── 左侧控制面板 ── */} -
+ {/* ── 左侧控制面板:一屏容纳,不滚动 ── */} +
{/* 标题 */}
@@ -231,132 +90,57 @@ export default function ClassroomPage() { )}
-
- {/* 窗口选择 */} - - - {/* 路径选择 */} -
- 采集路径 -
- {PATH_OPTIONS.map(({ value, label, icon: PathIcon, brief }) => ( - - ))} -
- {/* 当前路径详细说明 */} -
- -

- {PATH_OPTIONS.find((p) => p.value === capture.capturePath)?.detail} -

-
-
- - {/* 状态 + 控制 */} -
-
-
- - {statusCfg.label} -
-
- 帧 {capture.stats.frames} - 段 {capture.stats.extracted} -
+ ) : ( + /* 配置态:窗口/路径/模式/设置 + 底部启动按钮 */ + <> +
+ + +
- -
- {capture.status !== 'capturing' ? ( - - ) : ( - - )} - {capture.status !== 'idle' && capture.status !== 'error' && ( - - )} - {capture.status === 'capturing' && ( - +
+ + {!capture.canStart && ( +

请先选择目标窗口

)}
-
- - {/* 采集模式 */} -
- 采集模式 -
- {MODE_OPTIONS.map(({ value, label, icon: ModeIcon }) => ( - - ))} -
-

- {MODE_OPTIONS.find((m) => m.value === capture.mode)?.desc} -

-
- - {/* 设置 */} - - - {/* 课程信息 */} - -
+ + )}
- {/* ── 右侧内容区 ── */} + {/* ── 右侧内容区:独立滚动 ── */}
{/* 错误提示 */} {capture.extractionError && ( @@ -371,76 +155,96 @@ export default function ClassroomPage() {
)} - {/* 条件渲染内容 */} - {capture.capturePath === 'fine' && ( - - )} - - {capture.capturePath === 'smart' && ( + {showIdleGuide ? ( + /* 空态:当前配置说明书 + 课程信息卡 */ +
+ + + +
+ ) : ( <> - - {capture.partialCount > 0 && ( -
- - 已增量分析 {capture.partialCount} 段,课后将快速合并生成笔记 -
+ {/* 运行/结果态:按采集路径条件渲染 */} + {capture.capturePath === 'fine' && ( + )} - {capture.transcribedCount > 0 && ( -
- - 已转写 {capture.transcribedCount} 段语音 -
+ + {capture.capturePath === 'smart' && ( + <> + + {capture.partialCount > 0 && ( +
+ + 已增量分析 {capture.partialCount} 段,课后将快速合并生成笔记 +
+ )} + {capture.transcribedCount > 0 && ( +
+ + 已转写 {capture.transcribedCount} 段语音 +
+ )} + {/* 音频健康警告:区分"从未启动"与"中途中断" */} + {capture.status === 'capturing' && !capture.audioHealth.isHealthy && ( +
+ + + {capture.audioHealth.chunkCount === 0 + ? '未检测到音频输入,音频采集可能未启动,请停止后重新开始' + : '音频输入中断,请检查系统音频设置'} + +
+ )} + {/* VAD 状态(校准中提示) */} + {capture.status === 'capturing' && capture.vadStats && !capture.vadStats.calibrated && ( +
+ + 正在校准音频阈值 ({capture.vadStats.processedChunks}/10 块)... +
+ )} + {/* 实时转录上屏 */} + + )} - {/* 音频健康警告:区分“从未启动”与“中途中断” */} - {capture.status === 'capturing' && !capture.audioHealth.isHealthy && ( -
- - - {capture.audioHealth.chunkCount === 0 - ? '未检测到音频输入,音频采集可能未启动,请停止后重新开始' - : '音频输入中断,请检查系统音频设置'} - -
+ + {capture.capturePath === 'full_record' && ( + )} - {/* VAD 状态(校准中提示) */} - {capture.status === 'capturing' && capture.vadStats && !capture.vadStats.calibrated && ( -
- - 正在校准音频阈值 ({capture.vadStats.processedChunks}/10 块)... -
+ + {/* 分析预览 */} + {(capture.isAnalyzing || capture.analysisResult || capture.analysisError) && ( + void handleOpenNoteDialog()} + onDismiss={capture.handleDismissAnalysis} + onRetry={capture.analysisResult?.modelUsed === 'local-concat' ? undefined : capture.handleAnalyze} + onGenerateCards={capture.handleGenerateCards} + /> )} - {/* 实时转录上屏 */} - )} - {capture.capturePath === 'full_record' && ( - - )} - - {/* 分析预览 */} - {(capture.isAnalyzing || capture.analysisResult || capture.analysisError) && ( - void handleOpenNoteDialog()} - onDismiss={capture.handleDismissAnalysis} - onRetry={capture.analysisResult?.modelUsed === 'local-concat' ? undefined : capture.handleAnalyze} - onGenerateCards={capture.handleGenerateCards} - /> - )} - {/* 笔记插入弹窗 */} {showNoteDialog && capture.analysisResult && ( { + onDone={() => { setShowNoteDialog(false); capture.handleDismissAnalysis(); }} @@ -461,108 +265,3 @@ export default function ClassroomPage() {
); } - -// ================================================================ -// 设置区(内联) -// ================================================================ - -function SettingsSection({ config, onChange }: { - config: CaptureSidebarConfig; - onChange: (patch: Partial) => void; -}) { - return ( -
-
- - 设置 -
-
- - onChange({ screenshotInterval: Number(e.target.value) * 1000 })} - className="w-full accent-brand-600" /> -
-
- -
- {LANGUAGE_OPTIONS.map(({ value, label }) => ( - - ))} -
-
-
- ); -} - -// ================================================================ -// 课程信息区(内联) -// ================================================================ - -const SUBJECT_OPTIONS = [ - { value: 'math', label: '数学' }, - { value: 'physics', label: '物理' }, - { value: 'cs', label: '计算机' }, - { value: 'english', label: '英语' }, - { value: 'other', label: '其他' }, -]; - -function CourseInfoSection({ courseMeta, onChange, aiDetectEnabled, onAiDetectToggle }: { - courseMeta: CourseMeta; - onChange: (meta: CourseMeta) => void; - aiDetectEnabled: boolean; - onAiDetectToggle: (enabled: boolean) => void; -}) { - return ( -
-
- - 课程信息 - {courseMeta.detectedBy && ( - - {courseMeta.detectedBy === 'ai' ? 'AI 识别' : courseMeta.detectedBy === 'window_title' ? '自动提取' : '手动'} - - )} -
-
- - onChange({ ...courseMeta, courseName: e.target.value || undefined, detectedBy: 'manual' })} - placeholder="如:高等数学、数据结构..." - className="w-full px-2.5 py-1.5 rounded-kb-sm text-b3 bg-bg-secondary border border-border/30 text-text-primary placeholder:text-text-tertiary/50 focus:outline-none focus:ring-1 focus:ring-brand-300" - /> -
-
- -
- {SUBJECT_OPTIONS.map(({ value, label }) => ( - - ))} -
-
- {/* AI 识别开关 */} - -
- ); -} diff --git a/client/src/features/dashboard/components/StartupRitual.tsx b/client/src/features/dashboard/components/StartupRitual.tsx index 7a25587d..4e29bdbc 100644 --- a/client/src/features/dashboard/components/StartupRitual.tsx +++ b/client/src/features/dashboard/components/StartupRitual.tsx @@ -1,237 +1,156 @@ /** - * @ai-context: 通用组件:StartupRitual。 + * StartupRitual — 学习启动仪式容器(呼吸容器编排 + a11y,无业务副作用) + * Startup ritual container (breathing orchestration + a11y, no side effects) + * + * @ai-context: v0.26.0 A2——BreathingProvider 包裹全程步骤区(RIT-01 呼吸即 + * 容器),相位/整圈事件经 useBreathGuideSound 驱动引导音(RIT-14)。完成 + * 后切入 'complete' 收尾屏(RitualComplete:今日卡/火种/光尘)。容器只做 + * 编排、受控状态与 a11y(RIT-22/24),副作用由页面层经 ritualService 执行。 + * @ai-context: BreathingProvider wraps all steps; completion switches to a + * summary screen. Container does orchestration/state/a11y only. */ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { BookOpen, Target, Wind, Check, LineSquiggle, X, ChevronRight, SkipForward } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { MicroGoal, LastSessionData, MasteryMark, RitualStep } from '../types'; -import { calculateBreathingPhase } from '../utils/breathing'; +import { useState, useRef, useCallback } from 'react'; +import { Volume2, VolumeX } from 'lucide-react'; +import type { LastSessionData, MicroGoal, QuickTag, RitualOutcome, RitualSkipScope, RitualPlan, MemoryEchoItem, RecallQuestion } from '../types'; +import type { MasteryMark } from '@/types/ritual'; +import { useRitualMachine } from '../hooks/useRitualMachine'; +import { useRitualA11y } from '../hooks/useRitualA11y'; +import { useBreathGuideSound } from '../hooks/useBreathGuideSound'; +import { BreathingProvider } from './ritual/BreathingProvider'; +import { RitualStepReview } from './ritual/RitualStepReview'; +import { RitualStepGoal } from './ritual/RitualStepGoal'; +import { RitualStepBreathing } from './ritual/RitualStepBreathing'; +import { RitualComplete } from './ritual/RitualComplete'; +import { RitualSkipMenu } from './ritual/RitualSkipMenu'; +import { RitualFooter } from './ritual/RitualFooter'; interface Props { - onComplete: (goal?: MicroGoal) => void; - onSkip: () => void; + onComplete: (outcome: RitualOutcome) => void; + onSkip: (scope: RitualSkipScope) => void; lastSession?: LastSessionData; + quickTags?: QuickTag[]; + /** 含今天在内的火种连续天数(页面层预算) */ + streakDays?: number; + /** 呼吸引导音初始开关(来自 RitualSettings.soundOn) */ + soundOn?: boolean; + /** 静音开关变更回调(持久化到 RitualSettings) */ + onSoundToggle?: (on: boolean) => void; + /** 自适应编排计划(B1.1,缺省走默认三步) */ + plan?: RitualPlan; + /** 记忆回响时间线数据(B1.4) */ + recentEchoes?: MemoryEchoItem[]; + /** AI 回顾小问(B1.2);null 时回退遮罩摘要 */ + recallQuestion?: RecallQuestion | null; } -const STEPS: RitualStep[] = ['review', 'goal', 'breathing']; - -/* ── 掌握标记按钮 ── */ -const MASTERY_OPTIONS: { mark: MasteryMark; icon: typeof Check; label: string; color: string }[] = [ - { mark: 'mastered', icon: Check, label: '已掌握', color: 'text-moss' }, - { mark: 'fuzzy', icon: LineSquiggle, label: '模糊', color: 'text-amber' }, - { mark: 'unmastered', icon: X, label: '未掌握', color: 'text-semantic-error' }, -]; - -export default function StartupRitual({ onComplete, onSkip, lastSession }: Props) { - const [step, setStep] = useState(0); +export default function StartupRitual(props: Props) { + const { + onComplete, onSkip, lastSession, quickTags = [], streakDays = 1, + soundOn = false, onSoundToggle, plan, recentEchoes = [], recallQuestion = null, + } = props; + const machine = useRitualMachine(plan?.steps, plan?.planVariant); const [mastery, setMastery] = useState(null); const [goalText, setGoalText] = useState(''); - const [breathingElapsed, setBreathingElapsed] = useState(0); - - const rafRef = useRef(0); - const startRef = useRef(0); - - /* ── 呼吸动画 RAF ── */ - useEffect(() => { - if (step !== 2) return; - startRef.current = performance.now(); - function tick(now: number) { - setBreathingElapsed(now - startRef.current); - rafRef.current = requestAnimationFrame(tick); - } - rafRef.current = requestAnimationFrame(tick); - return () => cancelAnimationFrame(rafRef.current); - }, [step]); - - const breathing = calculateBreathingPhase(breathingElapsed); - - const next = useCallback(() => { - if (step < 2) { setStep((s) => s + 1); return; } - // 最后一步完成 - const goal: MicroGoal | undefined = goalText.trim() - ? { text: goalText.trim(), tags: [] } - : undefined; - onComplete(goal); - }, [step, goalText, onComplete]); - - const currentStep = STEPS[step]; + const [pickedTags, setPickedTags] = useState([]); + const [cycleLit, setCycleLit] = useState(false); + const [done, setDone] = useState(false); + const [sound, setSound] = useState(soundOn); + const cardRef = useRef(null); + + const { onPhaseChange, onCycleComplete } = useBreathGuideSound(sound); + + const buildOutcome = useCallback((): RitualOutcome => ({ + goal: goalText.trim() ? ({ text: goalText.trim(), tags: pickedTags } as MicroGoal) : undefined, + masteryMark: mastery ?? undefined, + durationMs: machine.getElapsedMs(), + planVariant: machine.planVariant, + }), [goalText, pickedTags, mastery, machine]); + + const handleNext = useCallback(() => { + if (machine.isLast) { setDone(true); return; } + machine.next(); + }, [machine]); + + const handlePickTag = useCallback((tag: QuickTag) => { + setGoalText(tag.text); + setPickedTags((prev) => (prev.includes(tag.text) ? prev : [...prev, tag.text])); + }, []); + + const toggleSound = useCallback(() => { + setSound((s) => { onSoundToggle?.(!s); return !s; }); + }, [onSoundToggle]); + + /* a11y:初始聚焦 + Esc/Enter 快捷键 + Tab 焦点陷阱(RIT-24) */ + const handleKeyDown = useRitualA11y(cardRef, () => onSkip('once'), handleNext); return (
- {/* 玻璃拟态主卡片 */} -
- {/* 顶部渐变蒙版 */} +
- - {/* 跳过按钮 */} - - -
- {/* ── Step 1: 回顾闪回 ── */} - {currentStep === 'review' && ( -
-
- - 回顾闪回 -
- {lastSession ? ( -
-

上次学到

-

- {lastSession.noteTitle} -

-

- …{lastSession.noteExcerpt} -

- {/* 掌握标记 */} -
- {MASTERY_OPTIONS.map(({ mark, icon: Icon, label, color }) => ( - - ))} -
-
- ) : ( -
-

还没有学习记录,开始新旅程吧 ✨

-
- )} -
- )} - - {/* ── Step 2: 微目标 ── */} - {currentStep === 'goal' && ( -
-
- - 设定微目标 -
-
- setGoalText(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && next()} - placeholder="本次我要完成 ____" - autoFocus - className={cn( - 'w-full px-4 py-3.5 rounded-kb-lg', - 'bg-bg-secondary/40 border border-border/60', - 'text-text-primary text-sm placeholder:text-text-tertiary/70', - 'outline-none focus:border-focus/60 focus:ring-2 focus:ring-focus/20', - 'transition-all duration-200', - )} + {!done && } + + {/* 呼吸引导音开关(左上) */} + {!done && ( + + )} + + +
+ {done ? ( + onComplete(buildOutcome())} + /> + ) : ( + <> + {machine.currentStep === 'review' && ( + + )} + {machine.currentStep === 'goal' && ( + + )} + {machine.currentStep === 'breathing' && ( + setCycleLit(true)} /> + )} + -
-

写下一个具体、可衡量的小目标,帮助自己聚焦注意力

-
- )} - - {/* ── Step 3: Box Breathing ── */} - {currentStep === 'breathing' && ( -
-
- - Box Breathing -
- - {/* 方形呼吸动画区 */} -
- {/* 方形轨道(虚线) */} -
- - {/* 移动方块 — CSS @keyframes 方形路径 */} -
- - {/* 中心阶段文字 */} -
- - {breathing.phaseLabel} - - - 第 {breathing.cycleCount + 1} 圈 - -
-
- -

- 跟随方块的节奏:吸气 4 秒 → 屏息 4 秒 → 呼气 4 秒 → 屏息 4 秒 -

-
- )} - - {/* ── 底部操作区 ── */} -
- {/* 步骤指示器 */} -
- {STEPS.map((_, i) => ( -
- ))} -
- - + + )}
-
+
- {/* CSS @keyframes 注入(方形路径 translate 动画) */}