diff --git a/CLAUDE.md b/CLAUDE.md index a29584f9..057f507b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,7 +170,7 @@ Service Worker (后台执行) 1. Context 回调中必须使用函数式更新 `setState(prev => ...)` 2. Hover 延迟操作需要正确的计时器清理 -3. 第三方窗口需用 `createPortal` 保持 React 事件流 +3. 弹出菜单/浮层需用 `createPortal` 渲染到 body,避免被父容器 overflow 截断 4. 模式切换时多个相关状态需同步更新,封装成一个函数而非直接暴露底层 setMode 5. 图标组件使用 `React.FC`,支持 `size` 属性 6. 传递组件作为 prop 时必须实例化:`icon={}` 而非 `icon={Icon}` @@ -211,6 +211,7 @@ Service Worker (后台执行) - **可点击容器**:用 `pointer-events: none` + 父容器 `onClick` 扩大交互区域 - **筛选与选中联动**:选中计数、操作都应基于筛选后的结果 - **全局配色统一**:在 `tdesign-theme.scss` 中集中覆盖第三方组件样式 +- **组件内颜色**:TDesign CSS 变量可能被覆盖,组件 scss 中应直接使用具体颜色值(如 `rgba(243, 156, 18, 0.12)`) --- diff --git a/apps/web/public/sw-debug/app.js b/apps/web/public/sw-debug/app.js index 5fc93ced..aef91a01 100644 --- a/apps/web/public/sw-debug/app.js +++ b/apps/web/public/sw-debug/app.js @@ -33,6 +33,7 @@ import { registerMessageHandlers, onControllerChange, setPostMessageLogCallback, + ensureDuplexInitialized, } from './sw-communication.js'; // Feature modules @@ -661,6 +662,7 @@ function handleStatusUpdate(data) { state.debugEnabled = updateStatusPanel(status, elements); state.swStatus = status; // Store for export updateDebugButton(elements.toggleDebugBtn, state.debugEnabled); + try { sessionStorage.setItem('sw-debug-enabled', String(state.debugEnabled)); } catch {} } // ==================== Event Listeners Setup ==================== @@ -874,6 +876,7 @@ function setupMessageHandlers() { 'SW_DEBUG_STATUS': handleStatusUpdate, 'SW_DEBUG_ENABLED': () => { state.debugEnabled = true; + try { sessionStorage.setItem('sw-debug-enabled', 'true'); } catch {} updateDebugButton(elements.toggleDebugBtn, true); // Update status panel text to show "开启" if (elements.debugMode) { @@ -888,6 +891,7 @@ function setupMessageHandlers() { }, 'SW_DEBUG_DISABLED': () => { state.debugEnabled = false; + try { sessionStorage.setItem('sw-debug-enabled', 'false'); } catch {} updateDebugButton(elements.toggleDebugBtn, false); // Update status panel text to show "关闭" if (elements.debugMode) { @@ -1178,16 +1182,18 @@ async function init() { setupMessageHandlers(); - // Auto-enable debug mode first when entering debug page - // The SW_DEBUG_ENABLED handler will then call refreshStatus() to get latest state - // This ensures proper state synchronization and avoids race conditions + // 先建立 duplex 通道并订阅广播(CONSOLE_LOG、POSTMESSAGE_LOG 等),再启用调试 + // 否则 SW 启用调试后开始转发日志时,调试页尚未在 channel-manager 中注册,收不到任何日志 + console.log('[SW Debug] Initializing duplex channel before enabling debug'); + const duplexOk = await ensureDuplexInitialized(); + if (!duplexOk) { + console.warn('[SW Debug] Duplex init failed, console/postmessage logs may not stream'); + } + + // Auto-enable debug mode when entering debug page console.log('[SW Debug] Auto-enabling debug mode'); enableDebug(); - - // 等待 SW 处理 debug mode 启用消息 - // 这确保 SW 端的 duplex channel 已经建立 - await new Promise(resolve => setTimeout(resolve, 500)); - + // Load fetch logs (existing logs from before debug mode was enabled won't exist, // but logs generated during this session will be available) loadFetchLogs(); diff --git a/apps/web/public/sw-debug/console-entry.js b/apps/web/public/sw-debug/console-entry.js index fe3cddf9..1b7f1455 100644 --- a/apps/web/public/sw-debug/console-entry.js +++ b/apps/web/public/sw-debug/console-entry.js @@ -19,6 +19,88 @@ function formatStack(stack) { }).filter(Boolean).join('\n'); } +/** + * 尝试解析 JSON 格式的日志消息 + * @param {unknown} msg + * @returns {{ parsed: boolean, message: string, stack?: string, source?: string, extra?: object }} + */ +function parseLogMessage(msg) { + if (msg == null) return { parsed: false, message: '' }; + + const msgStr = typeof msg === 'string' ? msg : ( + typeof msg === 'object' ? JSON.stringify(msg) : String(msg) + ); + + // 尝试解析 JSON 格式的消息 + if (msgStr.startsWith('{') && msgStr.endsWith('}')) { + try { + const obj = JSON.parse(msgStr); + if (obj && typeof obj === 'object') { + const result = { + parsed: true, + message: obj.message || msgStr, + stack: obj.stack || undefined, + source: obj.source || undefined, + }; + // 收集其他额外字段 + const extra = {}; + for (const key of Object.keys(obj)) { + if (!['message', 'stack', 'source'].includes(key)) { + extra[key] = obj[key]; + } + } + if (Object.keys(extra).length > 0) { + result.extra = extra; + } + return result; + } + } catch { + // 解析失败,使用原始字符串 + } + } + + return { parsed: false, message: msgStr }; +} + +/** + * 格式化 JSON 对象为带语法高亮的 HTML + * @param {unknown} obj + * @param {number} indent + * @returns {string} + */ +function formatJsonHtml(obj, indent = 0) { + const indentStr = ' '.repeat(indent); + + if (obj === null) { + return 'null'; + } + if (typeof obj === 'boolean') { + return `${obj}`; + } + if (typeof obj === 'number') { + return `${obj}`; + } + if (typeof obj === 'string') { + // 截断过长的字符串 + const displayStr = obj.length > 500 ? obj.substring(0, 500) + '...' : obj; + return `"${escapeHtml(displayStr)}"`; + } + if (Array.isArray(obj)) { + if (obj.length === 0) return '[]'; + const items = obj.map(item => `${indentStr} ${formatJsonHtml(item, indent + 1)}`); + return `[\n${items.join(',\n')}\n${indentStr}]`; + } + if (typeof obj === 'object') { + const keys = Object.keys(obj); + if (keys.length === 0) return '{}'; + const items = keys.map(key => + `${indentStr} "${escapeHtml(key)}": ${formatJsonHtml(obj[key], indent + 1)}` + ); + return `{\n${items.join(',\n')}\n${indentStr}}`; + } + return escapeHtml(String(obj)); +} + /** * Create a console log entry DOM element * Uses the same styles as Fetch logs for consistency @@ -33,8 +115,19 @@ export function createConsoleEntry(log, isExpanded = false, onToggle = null) { entry.className = `log-entry console-entry ${level}` + (isExpanded ? ' expanded' : ''); entry.dataset.id = log.id; - const hasStack = log.logStack && log.logStack.trim(); - const hasDetails = hasStack || log.logSource || log.url; + // 解析日志消息 + const parsedMsg = parseLogMessage(log.logMessage); + const displayMessage = parsedMsg.message; + + // 合并来自 log 对象和解析出的信息 + const stack = log.logStack?.trim() || parsedMsg.stack?.trim() || ''; + const source = log.logSource || parsedMsg.source || ''; + const hasStack = !!stack; + const hasSource = !!source; + const hasExtra = parsedMsg.extra && Object.keys(parsedMsg.extra).length > 0; + + // 总是显示展开按钮(消息长度超过 80 字符或有详细信息) + const hasDetails = hasStack || hasSource || log.url || hasExtra || displayMessage.length > 80; // Map log level to status class const levelStatusClass = { @@ -45,28 +138,28 @@ export function createConsoleEntry(log, isExpanded = false, onToggle = null) { 'debug': 'pending', }[level] || 'pending'; - // Show full message in header (will wrap if needed) - const messagePreview = log.logMessage || '-'; + // 头部显示截断的消息 + const headerMessage = displayMessage.length > 120 + ? displayMessage.substring(0, 120) + '...' + : displayMessage; entry.innerHTML = `
${hasDetails ? `▶` : ''} ${formatTime(log.timestamp)} ${level.toUpperCase()} - ${escapeHtml(messagePreview)} + ${escapeHtml(headerMessage)}
${hasDetails ? `
- ${log.logMessage ? ` -
-

完整消息

-
${escapeHtml(log.logMessage)}
-
- ` : ''} - ${log.logSource ? ` +
+

完整消息

+
${escapeHtml(displayMessage)}
+
+ ${hasSource ? `

来源

-
${escapeHtml(log.logSource)}
+
${escapeHtml(source)}
` : ''} ${log.url ? ` @@ -78,7 +171,13 @@ export function createConsoleEntry(log, isExpanded = false, onToggle = null) { ${hasStack ? `

堆栈

-
${formatStack(log.logStack)}
+
${formatStack(stack)}
+
+ ` : ''} + ${hasExtra ? ` +
+

其他信息

+
${formatJsonHtml(parsedMsg.extra)}
` : ''}
diff --git a/apps/web/public/sw-debug/styles.css b/apps/web/public/sw-debug/styles.css index e6955240..43716d26 100644 --- a/apps/web/public/sw-debug/styles.css +++ b/apps/web/public/sw-debug/styles.css @@ -991,6 +991,10 @@ button .icon-svg { } /* Console Entry - 继承 log-entry 样式,添加左侧边框指示日志级别 */ +.console-entry { + cursor: pointer; +} + .console-entry.warn { border-left: 3px solid var(--warning-color); background: var(--warning-light); @@ -1014,6 +1018,70 @@ button .icon-svg { background: rgba(90, 79, 207, 0.05); } +/* Console entry toggle button - more prominent */ +.console-entry .log-toggle { + width: 18px; + height: 18px; + background: var(--bg-card); + border: 1px solid var(--border-color); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.console-entry.error .log-toggle { + border-color: var(--error-color); + color: var(--error-color); +} + +.console-entry.warn .log-toggle { + border-color: var(--warning-color); + color: var(--warning-color); +} + +.console-entry:hover .log-toggle { + background: var(--bg-tertiary); + transform: scale(1.05); +} + +.console-entry.expanded .log-toggle { + transform: scale(1.05); +} + +/* Console message pre - better formatting */ +.console-message-pre { + font-size: 12px; + line-height: 1.5; + color: var(--text-primary); +} + +.console-entry.error .console-message-pre { + color: var(--error-color); +} + +.console-entry.warn .console-message-pre { + color: var(--warning-color); +} + +/* Console source pre - clickable link style */ +.console-source-pre { + color: var(--info-color); + font-size: 11px; + cursor: pointer; + word-break: break-all; +} + +.console-source-pre:hover { + text-decoration: underline; +} + +/* Console stack pre - error color */ +.console-stack-pre { + font-size: 11px; + line-height: 1.6; + border-left: 2px solid var(--error-color); + padding-left: 10px; + margin-left: 0; +} + .console-source { color: var(--text-muted); font-size: 11px; @@ -2941,45 +3009,44 @@ body.analysis-mode .log-entry { word-break: break-word; } +/* Light theme JSON colors (default) */ .json-key { - color: #9cdcfe; + color: #0451a5; } .json-string { - color: #ce9178; + color: #a31515; } .json-number { - color: #b5cea8; + color: #098658; } .json-boolean { - color: #569cd6; + color: #0000ff; } .json-null { + color: #0000ff; +} + +/* Dark theme JSON colors */ +[data-theme="dark"] .json-key { + color: #9cdcfe; +} + +[data-theme="dark"] .json-string { + color: #ce9178; +} + +[data-theme="dark"] .json-number { + color: #b5cea8; +} + +[data-theme="dark"] .json-boolean { color: #569cd6; } -/* Light mode JSON highlighting */ -@media (prefers-color-scheme: light) { - .json-key { - color: #0451a5; - } - - .json-string { - color: #a31515; - } - - .json-number { - color: #098658; - } - - .json-boolean { - color: #0000ff; - } - - .json-null { - color: #0000ff; - } +[data-theme="dark"] .json-null { + color: #569cd6; } diff --git a/apps/web/public/sw-debug/sw-communication.js b/apps/web/public/sw-debug/sw-communication.js index 5ef84044..4cde0e56 100644 --- a/apps/web/public/sw-debug/sw-communication.js +++ b/apps/web/public/sw-debug/sw-communication.js @@ -251,10 +251,10 @@ function setupDuplexEventHandlers() { } }); - // Console 日志 + // Console 日志(duplex 广播,与 SW_CONSOLE_LOG 原生消息格式一致:{ entry }) addDuplexEventListener(SW_EVENTS.CONSOLE_LOG, (data) => { - if (messageHandlers['SW_DEBUG_CONSOLE_LOG']) { - messageHandlers['SW_DEBUG_CONSOLE_LOG'](data); + if (messageHandlers['SW_CONSOLE_LOG']) { + messageHandlers['SW_CONSOLE_LOG'](data); } }); @@ -284,6 +284,7 @@ function setupDuplexEventHandlers() { * Enable debug mode */ export function enableDebug() { + try { sessionStorage.setItem('sw-debug-enabled', 'true'); } catch {} enableDebugMode(); } @@ -291,6 +292,7 @@ export function enableDebug() { * Disable debug mode */ export function disableDebug() { + try { sessionStorage.setItem('sw-debug-enabled', 'false'); } catch {} disableDebugMode(); } diff --git a/apps/web/public/version.json b/apps/web/public/version.json index 23908f26..20dc5d02 100644 --- a/apps/web/public/version.json +++ b/apps/web/public/version.json @@ -1,6 +1,6 @@ { "version": "0.5.63", - "buildTime": "2026-01-31T14:18:53.358Z", + "buildTime": "2026-02-01T12:41:20.792Z", "gitCommit": "unknown", "changelog": [ "添加\"不再提示\"按钮到灵感创意板块" diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 47b05838..2c58e412 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -19,6 +19,12 @@ import { import { sanitizeObject, sanitizeUrl } from '@aitu/utils'; import { initSWConsoleCapture } from './utils/sw-console-capture'; +// ===== 控制台日志捕获(尽早初始化,确保默认 console 被改写) ===== +// 必须在其他业务代码之前执行,否则后续工具(如 rrweb)可能先改写 console 导致捕获失效 +if ('serviceWorker' in navigator) { + initSWConsoleCapture(); +} + // ===== 崩溃恢复检测 ===== // 必须最先执行,检测上次是否因内存不足等原因崩溃 crashRecoveryService.markLoadingStart(); @@ -144,10 +150,7 @@ if ('serviceWorker' in navigator) { .then(registration => { // console.log('Service Worker registered successfully:', registration); swRegistration = registration; - - // 初始化控制台日志捕获,发送到 SW 调试面板 - initSWConsoleCapture(); - + // 在开发模式下,强制检查更新并处理等待中的Worker if (isDevelopment) { // console.log('Development mode: forcing SW update check'); diff --git a/apps/web/src/sw/index.ts b/apps/web/src/sw/index.ts index 59e6c3f7..1e2f49c5 100644 --- a/apps/web/src/sw/index.ts +++ b/apps/web/src/sw/index.ts @@ -19,6 +19,7 @@ import { } from './task-queue'; import { setDebugFetchBroadcast, + setDebugFetchEnabled, getInternalFetchLogs, clearInternalFetchLogs, isDebugFetchEnabled, @@ -86,8 +87,9 @@ taskQueue.setChannelManager(channelManager); channelManager.setDebugClientCountChangedCallback(handleDebugClientCountChanged); // ============================================================================ -// SW Console Log Capture (for debug mode) -// Intercepts SW internal console.log/info calls and forwards to debug panel +// SW Console Log Capture(应用页面 + Service Worker 日志均需捕获) +// - 调试开启:log/info/warn/error 均转发并记录、持久化 7 天、广播到调试面板 +// - 调试关闭:仅转发并记录 warn/error,持久化 7 天,不广播 // ============================================================================ const originalSWConsole = { log: console.log.bind(console), @@ -110,10 +112,10 @@ function shouldFilterLog(args: unknown[]): boolean { return true; } - // 过滤来自主线程的消息 + // 过滤来自主线程转发的重复消息(主线程通过 console:report 独立上报,不走 SW console) + // 注意:不在此过滤 "Service Worker",否则会误拦 SW 自身的日志如 "Service Worker [Video-xxx]: fetch failed" if (message.includes('[Main]') || - message.includes('[SW Console Capture]') || - message.includes('Service Worker')) { + message.includes('[SW Console Capture]')) { return true; } @@ -126,7 +128,7 @@ function shouldFilterLog(args: unknown[]): boolean { return false; } -// Forward SW console logs to debug panel when debug mode is enabled +// 转发 SW 内部 console 到 addConsoleLog(调试开:全部;调试关:仅 warn/error,均持久化 7 天) function setupSWConsoleCapture() { console.log = (...args: unknown[]) => { originalSWConsole.log(...args); @@ -142,7 +144,7 @@ function setupSWConsoleCapture() { } }; - // warn and error are always forwarded (but still filtered for loops) + // warn/error 始终转发并记录(调试关闭时也会持久化 7 天) console.warn = (...args: unknown[]) => { originalSWConsole.warn(...args); if (!shouldFilterLog(args)) { @@ -158,37 +160,54 @@ function setupSWConsoleCapture() { }; } +/** 序列化日志参数为字符串,Error 对象需特殊处理(message/stack 不可枚举,JSON.stringify 会得到 {}) */ +function formatLogArgs(args: unknown[]): { message: string; stack?: string } { + let extractedStack: string | undefined; + const parts: string[] = []; + for (const arg of args) { + try { + // Error 或类 Error 对象(跨 realm 时 instanceof 可能为 false) + const err = arg as { name?: string; message?: string; stack?: string }; + if (arg instanceof Error || (arg && typeof arg === 'object' && typeof err.message === 'string')) { + extractedStack = err.stack || extractedStack; + parts.push(`${err.name || 'Error'}: ${err.message || ''}`); + } else if (typeof arg === 'object' && arg !== null) { + const str = JSON.stringify(arg); + parts.push(str === '{}' ? String(arg) : str); + } else { + parts.push(String(arg)); + } + } catch { + parts.push(String(arg)); + } + } + const message = parts.join(' ') || '(empty)'; + return { message, stack: extractedStack }; +} + function forwardSWConsoleLog( level: 'log' | 'info' | 'warn' | 'error', args: unknown[] ) { - const message = args - .map((arg) => { - if (typeof arg === 'object') { - try { - return JSON.stringify(arg); - } catch { - return String(arg); - } - } - return String(arg); - }) - .join(' '); + try { + const { message, stack } = formatLogArgs(args); - // Add [SW] prefix only if not already present - const prefixedMessage = - message.startsWith('[SW]') || message.startsWith('[SW-') - ? message - : `[SW] ${message}`; + // Add [SW] prefix only if not already present + const prefixedMessage = + message.startsWith('[SW]') || message.startsWith('[SW-') + ? message + : `[SW] ${message}`; - // Use the existing addConsoleLog function (defined later in this file) - // We'll call it after it's defined - if (typeof addConsoleLogLater === 'function') { - addConsoleLogLater({ - logLevel: level, - logMessage: prefixedMessage, - logSource: 'service-worker', - }); + if (typeof addConsoleLogLater === 'function') { + addConsoleLogLater({ + logLevel: level, + logMessage: prefixedMessage, + logStack: stack, + logSource: 'service-worker', + }); + } + } catch (e) { + originalSWConsole.error('[SW Console Capture] forwardSWConsoleLog failed:', e); } } @@ -292,7 +311,7 @@ const completedImageRequests = new Map(); const COMPLETED_REQUEST_CACHE_TTL = 30 * 1000; interface VideoRequestEntry { - promise: Promise; + promise: Promise; // symbol = VIDEO_LOAD_ERROR 表示下载失败 timestamp: number; count: number; requestId: string; @@ -476,13 +495,15 @@ async function saveConsoleLogToDB(logEntry: DebugLogEntry): Promise { } } -// 从 IndexedDB 加载所有控制台日志 +// 从 IndexedDB 加载控制台日志(仅返回 7 天内,加载前先清理过期) async function loadConsoleLogsFromDB(): Promise { try { + await cleanupExpiredConsoleLogs(); const db = await openConsoleLogDB(); const transaction = db.transaction(['logs'], 'readonly'); const store = transaction.objectStore('logs'); const index = store.index('timestamp'); + const expirationTime = Date.now() - CONSOLE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; return new Promise((resolve, reject) => { const request = index.openCursor(null, 'prev'); // 按时间倒序 @@ -491,7 +512,10 @@ async function loadConsoleLogsFromDB(): Promise { request.onsuccess = () => { const cursor = request.result; if (cursor) { - logs.push(cursor.value); + const entry = cursor.value as DebugLogEntry; + if (entry.timestamp >= expirationTime) { + logs.push(entry); + } cursor.continue(); } else { db.close(); @@ -575,11 +599,13 @@ async function clearAllConsoleLogs(): Promise { } } +// 控制台日志内存条数上限(避免无限增长) +const MAX_CONSOLE_LOGS_MEMORY = 500; + // 添加控制台日志 -// 注意:只有调试模式启用时才存储和广播日志 -// 这与主线程 sw-console-capture.ts 的设计一致: -// - 主线程:warn/error 始终发送,log/info/debug 只在调试模式时发送 -// - SW 端:只有调试模式启用时才处理日志,避免不必要的存储和广播 +// - 调试模式开启:记录所有级别(log/info/warn/error/debug),持久化 7 天并广播到调试面板 +// - 调试模式关闭:仅记录 warn 及以上,持久化 7 天,不广播 +// 应用页面与 Service Worker 的日志均经此入口(主线程通过 console:report,SW 通过 forwardSWConsoleLog) function addConsoleLog(entry: { logLevel: 'log' | 'info' | 'warn' | 'error' | 'debug'; logMessage: string; @@ -587,8 +613,9 @@ function addConsoleLog(entry: { logSource?: string; url?: string; }): void { - // 调试模式未启用时,不存储和广播日志 - if (!debugModeEnabled) { + const isWarnOrAbove = entry.logLevel === 'warn' || entry.logLevel === 'error'; + const shouldRecord = debugModeEnabled || isWarnOrAbove; + if (!shouldRecord) { return; } @@ -600,14 +627,19 @@ function addConsoleLog(entry: { ...entry, }; - // 保存到内存(用于 getDebugStatus 统计) - consoleLogs.unshift(logEntry); - - // 保存到 IndexedDB(异步,不阻塞) + // 持久化到 IndexedDB(保留 7 天,由 cleanupExpiredConsoleLogs 清理) saveConsoleLogToDB(logEntry); - // 广播日志到调试页面 - broadcastConsoleLog(logEntry); + // 内存缓存:调试开启时存全部,关闭时只存 warn+(用于统计与调试面板加载) + consoleLogs.unshift(logEntry); + if (consoleLogs.length > MAX_CONSOLE_LOGS_MEMORY) { + consoleLogs.length = MAX_CONSOLE_LOGS_MEMORY; + } + + // 仅调试模式开启时广播到调试面板 + if (debugModeEnabled) { + broadcastConsoleLog(logEntry); + } } // Set the forward function now that addConsoleLog is defined @@ -713,34 +745,27 @@ function clearConsoleLogs(): void { } // 启用调试模式(当调试页面连接时自动启用) -async function enableDebugMode(): Promise { +function enableDebugMode(): void { if (debugModeEnabled) return; // 避免重复启用 - + debugModeEnabled = true; - - // Sync debug mode to debugFetch and message sender - const { setDebugFetchEnabled } = await import('./task-queue/debug-fetch'); setDebugFetchEnabled(true); setMessageSenderDebugMode(true); - + originalSWConsole.log('Service Worker: Debug mode enabled (debug page connected)'); } // 禁用调试模式(当所有调试页面关闭时自动禁用) -async function disableDebugMode(): Promise { +function disableDebugMode(): void { if (!debugModeEnabled) return; // 避免重复禁用 - + debugModeEnabled = false; - - // Sync debug mode to debugFetch and message sender - const { setDebugFetchEnabled } = await import('./task-queue/debug-fetch'); setDebugFetchEnabled(false); setMessageSenderDebugMode(false); - // 禁用调试模式时清空所有日志 + // 禁用调试时仅清空内存;IndexedDB 中 warn+ 日志保留 7 天 consoleLogs.length = 0; debugLogs.length = 0; - await clearAllConsoleLogs().catch(() => {}); originalSWConsole.log('Service Worker: Debug mode disabled (no debug pages)'); } @@ -1482,12 +1507,9 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => { console.error('Service Worker: Failed to clear all cache:', error); }); } else if (event.data && event.data.type === 'SW_DEBUG_ENABLE') { - // 启用调试模式 + // 启用调试模式(同步设置 debugFetch,否则 log/info 会因 isDebugFetchEnabled 延迟为 true 而漏捕) debugModeEnabled = true; - // Sync debug mode to debugFetch and message sender - import('./task-queue/debug-fetch').then(({ setDebugFetchEnabled }) => { - setDebugFetchEnabled(true); - }); + setDebugFetchEnabled(true); setMessageSenderDebugMode(true); originalSWConsole.log('Service Worker: Debug mode enabled'); // 响应发送方(native message) @@ -1502,18 +1524,14 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => { } else if (event.data && event.data.type === 'SW_DEBUG_DISABLE') { // 禁用调试模式 debugModeEnabled = false; - // Sync debug mode to debugFetch and message sender - import('./task-queue/debug-fetch').then(({ setDebugFetchEnabled }) => { - setDebugFetchEnabled(false); - }); + setDebugFetchEnabled(false); setMessageSenderDebugMode(false); - // 禁用调试模式时清空所有日志(内存和 IndexedDB) + // 禁用调试时仅清空内存;IndexedDB 中 warn+ 日志保留 7 天供后续查看 consoleLogs.length = 0; debugLogs.length = 0; - clearAllConsoleLogs().catch(() => {}); - originalSWConsole.log('Service Worker: Debug mode disabled, logs cleared'); + originalSWConsole.log('Service Worker: Debug mode disabled'); // 响应发送方(native message) if (event.source) { (event.source as Client).postMessage({ type: 'SW_DEBUG_DISABLED' }); @@ -2885,6 +2903,9 @@ async function handleAssetLibraryRequest(request: Request): Promise { } } +// Sentinel:视频下载失败时返回此值,避免 throw 导致 Uncaught (in promise) +const VIDEO_LOAD_ERROR = Symbol('VIDEO_LOAD_ERROR'); + // 处理视频请求,支持 Range 请求以实现视频 seek 功能 async function handleVideoRequest(request: Request): Promise { const url = new URL(request.url); @@ -2958,7 +2979,16 @@ async function handleVideoRequest(request: Request): Promise { // 等待视频下载完成 const videoBlob = await existingEntry.promise; - if (!videoBlob) { + // 下载失败(404/403 等),返回 500 + if (videoBlob === VIDEO_LOAD_ERROR) { + return new Response('Video loading error', { + status: 500, + statusText: 'Internal Server Error', + headers: { 'Content-Type': 'text/plain' }, + }); + } + // 服务器支持 Range,重新 fetch + if (videoBlob === null) { const fetchOptions = { method: 'GET', headers: new Headers(request.headers), @@ -3005,87 +3035,74 @@ async function handleVideoRequest(request: Request): Promise { return createVideoResponse(videoBlob, rangeHeader, requestId); } - } catch (cacheError) { - console.warn( - `Service Worker [Video-${requestId}]: 检查 Cache API 失败:`, - cacheError - ); + } catch { + // 检查 Cache API 失败,继续下载 } // 创建新的视频下载Promise // console.log(`Service Worker [Video-${requestId}]: 开始下载新视频:`, dedupeKey); const downloadPromise = (async () => { - // 构建请求选项 - const fetchOptions = { - method: 'GET', - mode: 'cors' as RequestMode, - credentials: 'omit' as RequestCredentials, - cache: 'default' as RequestCache, // 使用浏览器默认缓存策略 - }; + try { + // 构建请求选项 + const fetchOptions = { + method: 'GET', + mode: 'cors' as RequestMode, + credentials: 'omit' as RequestCredentials, + cache: 'default' as RequestCache, // 使用浏览器默认缓存策略 + }; - // 获取视频响应(不带Range header,获取完整视频) - const fetchUrl = new URL(dedupeUrl); - const response = await fetch(fetchUrl, fetchOptions); + // 获取视频响应(不带Range header,获取完整视频) + const fetchUrl = new URL(dedupeUrl); + const response = await fetch(fetchUrl, fetchOptions); - if (!response.ok) { - console.error( - `Service Worker [Video-${requestId}]: Video fetch failed:`, - response.status - ); - throw new Error(`Video fetch failed: ${response.status}`); - } - - // 如果服务器返回206,说明服务器原生支持Range,直接返回不缓存 - if (response.status === 206) { - // console.log(`Service Worker [Video-${requestId}]: 服务器原生支持Range请求,直接返回`); - return null; // 返回null表示不缓存,直接使用服务器响应 - } - - // 下载完整视频 - // console.log(`Service Worker [Video-${requestId}]: 开始下载完整视频...`); - const videoBlob = await response.blob(); - const videoSizeMB = videoBlob.size / (1024 * 1024); - // console.log(`Service Worker [Video-${requestId}]: 视频下载完成 (大小: ${videoSizeMB.toFixed(2)}MB)`); - - // 缓存视频Blob(仅缓存小于50MB的视频) - if (videoSizeMB < 50) { - // 1. 内存缓存(用于当前会话快速访问) - videoBlobCache.set(dedupeKey, { - blob: videoBlob, - timestamp: Date.now(), - }); - // console.log(`Service Worker [Video-${requestId}]: 视频已缓存到内存`); - - // 2. 持久化到 Cache API(用于跨会话持久化) - try { - const cache = await caches.open(IMAGE_CACHE_NAME); - const cacheResponse = new Response(videoBlob, { - headers: { - 'Content-Type': videoBlob.type || 'video/mp4', - 'Content-Length': videoBlob.size.toString(), - 'sw-cache-date': Date.now().toString(), - 'sw-video-size': videoBlob.size.toString(), - }, - }); - await cache.put(dedupeKey, cacheResponse); - // console.log(`Service Worker [Video-${requestId}]: 视频已持久化到 Cache API`); - - // 异步生成预览图(不阻塞主流程) - // 使用与缓存key一致的URL(dedupeKey)作为预览图key - const { generateThumbnailAsync } = await import('./task-queue/utils/thumbnail-utils'); - generateThumbnailAsync(videoBlob, dedupeKey, 'video'); - } catch (cacheError) { - console.warn( - `Service Worker [Video-${requestId}]: 持久化到 Cache API 失败:`, - cacheError - ); + if (!response.ok) { + return VIDEO_LOAD_ERROR; } - } else { - // console.log(`Service Worker [Video-${requestId}]: 视频过大(${videoSizeMB.toFixed(2)}MB),不缓存`); - } - return videoBlob; + // 如果服务器返回206,说明服务器原生支持Range,直接返回不缓存 + if (response.status === 206) { + // console.log(`Service Worker [Video-${requestId}]: 服务器原生支持Range请求,直接返回`); + return null; // 返回null表示不缓存,直接使用服务器响应 + } + + // 下载完整视频 + // console.log(`Service Worker [Video-${requestId}]: 开始下载完整视频...`); + const videoBlob = await response.blob(); + const videoSizeMB = videoBlob.size / (1024 * 1024); + // console.log(`Service Worker [Video-${requestId}]: 视频下载完成 (大小: ${videoSizeMB.toFixed(2)}MB)`); + + // 缓存视频Blob(仅缓存小于50MB的视频) + if (videoSizeMB < 50) { + // 1. 内存缓存(用于当前会话快速访问) + videoBlobCache.set(dedupeKey, { + blob: videoBlob, + timestamp: Date.now(), + }); + // 2. 持久化到 Cache API(用于跨会话持久化) + try { + const cache = await caches.open(IMAGE_CACHE_NAME); + const cacheResponse = new Response(videoBlob, { + headers: { + 'Content-Type': videoBlob.type || 'video/mp4', + 'Content-Length': videoBlob.size.toString(), + 'sw-cache-date': Date.now().toString(), + 'sw-video-size': videoBlob.size.toString(), + }, + }); + await cache.put(dedupeKey, cacheResponse); + const { generateThumbnailAsync } = await import('./task-queue/utils/thumbnail-utils'); + generateThumbnailAsync(videoBlob, dedupeKey, 'video'); + } catch { + // 持久化到 Cache API 失败,内存缓存仍可用 + } + } + + + return videoBlob; + } catch { + return VIDEO_LOAD_ERROR; + } })(); // 将下载Promise存储到去重字典 @@ -3109,6 +3126,15 @@ async function handleVideoRequest(request: Request): Promise { // 等待视频下载完成 const videoBlob = await downloadPromise; + // 下载失败(404/403 等),返回 500 让前端处理 + if (videoBlob === VIDEO_LOAD_ERROR) { + return new Response('Video loading error', { + status: 500, + statusText: 'Internal Server Error', + headers: { 'Content-Type': 'text/plain' }, + }); + } + // 如果返回null,说明服务器支持Range,重新发送原始请求 if (videoBlob === null) { const fetchOptions = { @@ -3122,11 +3148,7 @@ async function handleVideoRequest(request: Request): Promise { // 使用下载的blob响应Range请求 return createVideoResponse(videoBlob as Blob, rangeHeader, requestId); - } catch (error) { - console.error( - `Service Worker [Video-${requestId}]: Video request error:`, - error - ); + } catch { return new Response('Video loading error', { status: 500, statusText: 'Internal Server Error', @@ -3165,9 +3187,6 @@ function createVideoResponse( // 解析Range header (格式: "bytes=start-end") const rangeMatch = rangeHeader.match(/bytes=(\d+)-(\d*)/); if (!rangeMatch) { - console.error( - `Service Worker [Video-${requestId}]: Invalid Range header format` - ); return new Response(videoBlob, { status: 200, statusText: 'OK', @@ -3879,7 +3898,6 @@ async function handleImageRequest(request: Request): Promise { throw timeoutError; } } catch (error) { - console.error('Service Worker fetch error:', error); throw error; } } @@ -4337,8 +4355,6 @@ async function handleImageRequestInternal( throw new Error(`HTTP ${response.status}: ${response.statusText}`); } catch (error: any) { - console.error('Service Worker fetch error:', error); - // 重新获取URL用于错误处理 const errorUrl = new URL(requestUrl); diff --git a/apps/web/src/sw/task-queue/channel-manager.ts b/apps/web/src/sw/task-queue/channel-manager.ts index a12b0617..5ac6cca3 100644 --- a/apps/web/src/sw/task-queue/channel-manager.ts +++ b/apps/web/src/sw/task-queue/channel-manager.ts @@ -1141,13 +1141,31 @@ export class SWChannelManager { // Console RPC 处理器 // ============================================================================ + /** + * 将单个日志参数序列化为字符串,避免对象显示为 [object Object] + */ + private serializeLogArg(arg: unknown): string { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'object') { + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + } + return String(arg); + } + private async handleConsoleReport(data: ConsoleReportParams): Promise<{ success: boolean; error?: string }> { try { const { addConsoleLog } = await import('../index'); - // addConsoleLog expects a single entry object, not separate arguments + const logArgs = data.logArgs ?? []; + const parts = Array.isArray(logArgs) ? logArgs.map((a) => this.serializeLogArg(a)) : [this.serializeLogArg(logArgs)]; + const logMessage = parts.join(' '); addConsoleLog({ logLevel: data.logLevel as 'log' | 'info' | 'warn' | 'error' | 'debug', - logMessage: Array.isArray(data.logArgs) ? data.logArgs.map(String).join(' ') : String(data.logArgs), + logMessage: logMessage || '-', }); return { success: true }; } catch (error: any) { @@ -1164,8 +1182,8 @@ export class SWChannelManager { const { getDebugStatus, getCacheStats } = await import('../index'); const status = getDebugStatus(); const cacheStats = await getCacheStats(); - // Return the full status object with cacheStats merged in - return { ...status, cacheStats }; + // 返回完整状态,同时提供 enabled 别名以兼容 DebugStatusResult 类型 + return { ...status, enabled: status.debugModeEnabled, cacheStats }; } catch { return { debugModeEnabled: false }; } diff --git a/apps/web/src/sw/task-queue/utils/thumbnail-utils.ts b/apps/web/src/sw/task-queue/utils/thumbnail-utils.ts index 000c8f79..fdbc411d 100644 --- a/apps/web/src/sw/task-queue/utils/thumbnail-utils.ts +++ b/apps/web/src/sw/task-queue/utils/thumbnail-utils.ts @@ -128,8 +128,23 @@ async function generateThumbnailForSize( return; // 已存在,无需重复生成 } - // 2. 使用 createImageBitmap 和 OffscreenCanvas 生成预览图 - const imageBitmap = await createImageBitmap(blob); + // 2. 验证 blob 可解码,避免 InvalidStateError 导致 SW 崩溃 + // 非图片类型(如 404 的 text/html)或空 blob 不能解码 + if (blob.size === 0) return; + const type = (blob.type || '').toLowerCase(); + if (!type.startsWith('image/') && type !== '') return; + + // 3. 使用 createImageBitmap 和 OffscreenCanvas 生成预览图 + let imageBitmap: ImageBitmap; + try { + imageBitmap = await createImageBitmap(blob); + } catch (decodeError) { + // InvalidStateError: 源图片无法解码(损坏/格式错误/非图片内容) + if (decodeError instanceof Error && decodeError.name === 'InvalidStateError') { + return; // 静默跳过,不重试 + } + throw decodeError; + } const { width, height } = calculateThumbnailSize( imageBitmap.width, imageBitmap.height, @@ -143,8 +158,11 @@ async function generateThumbnailForSize( return; } - ctx.drawImage(imageBitmap, 0, 0, width, height); - imageBitmap.close(); + try { + ctx.drawImage(imageBitmap, 0, 0, width, height); + } finally { + imageBitmap.close(); + } const thumbnailBlob = await canvas.convertToBlob({ type: 'image/jpeg', diff --git a/apps/web/src/utils/sw-console-capture.ts b/apps/web/src/utils/sw-console-capture.ts index 50ddf7a8..df4a37a5 100644 --- a/apps/web/src/utils/sw-console-capture.ts +++ b/apps/web/src/utils/sw-console-capture.ts @@ -9,7 +9,33 @@ import { swChannelClient } from '@drawnix/drawnix'; let isInitialized = false; -let debugModeEnabled = false; + +// 调试模式:sessionStorage 关掉浏览器自动清除 +const DEBUG_STORAGE_KEY = 'sw-debug-enabled'; +function getDebugModeFromStorage(): boolean { + try { + return sessionStorage.getItem(DEBUG_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} +function setDebugModeToStorage(enabled: boolean | undefined): void { + try { + sessionStorage.setItem(DEBUG_STORAGE_KEY, enabled === true ? 'true' : 'false'); + } catch { + // 忽略 + } +} +let debugModeEnabled = getDebugModeFromStorage(); + +// 日志队列:channel 未就绪时暂存,就绪后批量发送 +interface QueuedLog { + level: string; + message: string; + stack?: string; +} +const logQueue: QueuedLog[] = []; +let initPromise: Promise | null = null; // 保存原始的 console 方法 const originalConsole = { @@ -39,8 +65,9 @@ function sendToSW(level: string, message: string, stack?: string) { return; } - // 使用 swChannelClient 发送控制台日志 - if (swChannelClient.isInitialized()) { + // 发送单条日志 + const doSend = () => { + if (!swChannelClient.isInitialized()) return; swChannelClient.reportConsoleLog( level, [{ message, stack: stack || '', source: window.location.href }], @@ -48,6 +75,32 @@ function sendToSW(level: string, message: string, stack?: string) { ).catch(() => { // 忽略发送错误 }); + }; + + // 清空队列中的日志 + const flushQueue = () => { + while (logQueue.length > 0 && swChannelClient.isInitialized()) { + const item = logQueue.shift()!; + swChannelClient.reportConsoleLog( + item.level, + [{ message: item.message, stack: item.stack || '', source: window.location.href }], + Date.now() + ).catch(() => {}); + } + }; + + if (swChannelClient.isInitialized()) { + doSend(); + } else { + logQueue.push({ level, message, stack }); + // 触发初始化(复用同一 promise 避免重复调用) + if (!initPromise) { + initPromise = swChannelClient.initialize().then(() => { + flushQueue(); + }).catch(() => {}).finally(() => { + initPromise = null; + }); + } } } @@ -97,26 +150,40 @@ export function initSWConsoleCapture(): void { isInitialized = true; - // 订阅调试状态变更事件 + // 订阅调试状态变更事件 + 主动触发 channel 连接(否则早期日志会因 channel 未就绪而丢失) const setupDebugStatusListener = () => { if (swChannelClient.isInitialized()) { - // 订阅调试状态变更事件 swChannelClient.subscribeToEvent('debug:statusChanged', (data) => { - const event = data as { enabled: boolean }; - debugModeEnabled = event.enabled; + const event = data as { enabled?: boolean; debugModeEnabled?: boolean }; + const enabled = event.enabled ?? event.debugModeEnabled ?? false; + debugModeEnabled = enabled; + setDebugModeToStorage(enabled); }); - - // 查询当前调试状态 - swChannelClient.getDebugStatus().then((status) => { - debugModeEnabled = status.enabled; + swChannelClient.getDebugStatus().then((status: { enabled?: boolean; debugModeEnabled?: boolean }) => { + const enabled = status.enabled ?? status.debugModeEnabled ?? false; + debugModeEnabled = enabled; + setDebugModeToStorage(enabled); }); } else { - // 如果 swChannelClient 未初始化,延迟重试 + // 主动触发连接(SW 需在 load 后注册,此处延迟执行) + if (!initPromise) { + initPromise = swChannelClient.initialize().then(() => { + while (logQueue.length > 0 && swChannelClient.isInitialized()) { + const item = logQueue.shift()!; + swChannelClient.reportConsoleLog( + item.level, + [{ message: item.message, stack: item.stack || '', source: window.location.href }], + Date.now() + ).catch(() => {}); + } + }).catch(() => {}).finally(() => { + initPromise = null; + }); + } setTimeout(setupDebugStatusListener, 500); } }; - // 初始化调试状态监听 setupDebugStatusListener(); // 拦截 console.error(始终捕获) @@ -171,4 +238,5 @@ export function initSWConsoleCapture(): void { sendToSW('error', message, stack); }); + // sessionStorage 不跨 tab 共享,storage 事件仅对 localStorage 有效,故不监听 } diff --git a/docs/CODING_RULES.md b/docs/CODING_RULES.md index 0a663894..6ae90a6e 100644 --- a/docs/CODING_RULES.md +++ b/docs/CODING_RULES.md @@ -1019,6 +1019,96 @@ const handleTouchEnd = () => { - 触控结束后延迟隐藏预览,给用户时间查看结果 - Canvas 元素需要设置 `touch-action: none` 防止默认滚动行为 +#### TDesign CSS 变量优先级问题 + +**场景**: 在组件样式中使用 TDesign CSS 变量(如 `--td-brand-color-light`)时,发现颜色不生效或显示为默认颜色。 + +❌ **错误示例**: +```scss +// 在组件 scss 中使用 CSS 变量 +.project-drawer-node__row { + &--selected { + background: var(--td-brand-color-light); // 可能被 TDesign 内部样式覆盖 + + &:hover { + background: var(--td-brand-color-light-hover); // 不生效 + } + } +} +``` + +✅ **正确示例**: +```scss +// 直接使用具体颜色值 +.project-drawer-node__row { + &--selected { + background: rgba(243, 156, 18, 0.12); // 品牌橙金色 12% 透明度 + + &:hover { + background: rgba(243, 156, 18, 0.18); // 18% 透明度 + } + } +} +``` + +**原因**: TDesign 组件内部可能在更具体的选择器中定义了同名 CSS 变量的值,导致 `:root` 中的全局定义被覆盖。在组件级样式中直接使用具体颜色值可以确保样式正确生效。 + +**适用场景**: +- 组件内的选中/高亮/激活状态背景色 +- 需要使用品牌色变体的场景(如透明度变化) +- 任何发现 CSS 变量不生效的情况 + +#### 弹出菜单被父容器 overflow 截断 + +**场景**: 在设置了 `overflow: hidden/auto` 的容器内渲染右键菜单、下拉菜单等弹出层时,菜单被截断。 + +❌ **错误示例**: +```tsx +// 菜单直接渲染在组件内部,被父容器 overflow 截断 +return ( +
+ {/* 内容 */} + {contextMenu && ( +
+ {/* 菜单项 */} +
+ )} +
+); +``` + +✅ **正确示例**: +```tsx +import { createPortal } from 'react-dom'; + +return ( +
+ {/* 内容 */} + {/* 使用 Portal 渲染到 body,避免被截断 */} + {contextMenu && createPortal( +
+ {/* 菜单项 */} +
, + document.body + )} +
+); +``` + +**原因**: 即使使用 `position: fixed`,如果元素在 DOM 树中位于设置了 `overflow: hidden` 的容器内部,该容器仍然会裁切超出其边界的内容。使用 `createPortal` 将弹出层渲染到 `document.body`,使其脱离原有容器的裁切上下文。 + +**适用场景**: +- 右键菜单 +- 自定义下拉菜单 +- 悬浮预览框 +- 任何需要显示在容器边界外的浮层 + ### Git 提交规范 - 格式: `(): ` - 类型: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `ci` @@ -6222,3 +6312,101 @@ async restoreBoard(boardId: string) { - 跨设备同步:其他设备同步时能正确识别已删除的项目 - 永久删除可控:用户手动清空回收站时才真正删除远程文件 +### 异步操作不应阻塞 UI 交互 + +**场景**: 删除、保存等操作需要同步到远程服务器时 + +❌ **错误示例**: +```typescript +// 错误:同步操作阻塞了 UI,弹窗要等网络请求完成才关闭 +async deleteBoard(id: string): Promise { + // 用户点击确认后,弹窗卡住等待网络请求 + const result = await syncEngine.syncBoardDeletion(id); // 阻塞 2-3 秒 + if (result.success) { + console.log('Synced to remote'); + } + + // 本地删除 + this.boards.delete(id); + await storage.deleteBoard(id); +} +``` + +✅ **正确示例**: +```typescript +// 正确:异步执行同步,不阻塞本地操作 +async deleteBoard(id: string): Promise { + // 异步同步到远程(fire-and-forget) + import('./github-sync').then(({ syncEngine }) => { + syncEngine.syncBoardDeletion(id).then((result) => { + if (result.success) { + console.log('Board synced to remote recycle bin'); + } else { + console.warn('Failed to sync deletion:', result.error); + } + }).catch(console.warn); + }).catch(console.warn); + + // 本地删除立即执行,弹窗立即关闭 + this.boards.delete(id); + await storage.deleteBoard(id); +} +``` + +**原因**: +- 用户期望点击"确认"后弹窗立即关闭 +- 网络请求可能慢或失败,不应影响本地操作体验 +- 远程同步失败时可以在下次同步时重试 + +### 关键操作应直接调用,不依赖事件订阅 + +**场景**: 需要在某个操作后触发另一个操作(如删除后同步到远程) + +❌ **错误示例**: +```typescript +// 错误:依赖事件订阅来触发同步 +// 问题:事件订阅可能有时序问题,订阅在事件触发后才建立 + +// 在 Context 中订阅事件 +useEffect(() => { + const subscription = workspaceService.observeEvents().subscribe({ + next: (event) => { + if (event.type === 'boardDeleted') { + // 可能收不到事件!因为订阅建立时机不确定 + syncEngine.syncBoardDeletion(event.payload.id); + } + } + }); + return () => subscription.unsubscribe(); +}, []); + +// 在 Service 中发出事件 +async deleteBoard(id: string) { + this.boards.delete(id); + this.emit('boardDeleted', { id }); // 事件可能没有订阅者 +} +``` + +✅ **正确示例**: +```typescript +// 正确:在操作中直接调用,保证执行 +async deleteBoard(id: string) { + // 直接调用同步逻辑 + import('./github-sync').then(({ syncEngine }) => { + syncEngine.syncBoardDeletion(id).catch(console.warn); + }); + + // 执行本地删除 + this.boards.delete(id); + + // 事件仅用于 UI 更新等非关键操作 + this.emit('boardDeleted', { id }); +} +``` + +**原因**: +- RxJS Subject 的事件是同步分发的,但订阅者的建立是异步的 +- React useEffect 中的订阅可能在组件渲染后才建立 +- 热更新(HMR)可能导致旧的订阅被替换 +- 关键业务逻辑不应依赖于"某处可能存在的订阅者" + diff --git a/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx b/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx index e969a91a..828098b9 100644 --- a/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx +++ b/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx @@ -13,6 +13,7 @@ import React, { useEffect, DragEvent, } from 'react'; +import { createPortal } from 'react-dom'; import { Button, Input, @@ -81,6 +82,7 @@ const ProjectDrawerContent: React.FC<{ onCreateFolder: (parentId?: string) => void; onRename: (type: 'folder' | 'board', id: string, name: string) => void; onDelete: (type: 'folder' | 'board', id: string, name: string) => void; + onDeleteMultiple: (boardIds: string[]) => void; onCopyBoard: (id: string) => void; onMoveBoard: ( id: string, @@ -99,6 +101,14 @@ const ProjectDrawerContent: React.FC<{ autoEditBoardId?: string | null; /** 清除自动编辑状态 */ onAutoEditDone?: () => void; + /** 多选状态 */ + selectedBoardIds: Set; + /** 设置多选状态 */ + onSelectionChange: (ids: Set) => void; + /** 上次点击的画板 ID(用于 Shift 范围选择)*/ + lastClickedBoardId: string | null; + /** 设置上次点击的画板 ID */ + onLastClickedChange: (id: string | null) => void; }> = ({ tree, currentBoard, @@ -107,15 +117,32 @@ const ProjectDrawerContent: React.FC<{ onCreateFolder, onRename, onDelete, + onDeleteMultiple, onCopyBoard, onMoveBoard, onMoveFolder, toggleFolderExpanded, autoEditBoardId, onAutoEditDone, + selectedBoardIds, + onSelectionChange, + lastClickedBoardId, + onLastClickedChange, }) => { const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(''); + + // 右键菜单状态 + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + x: number; + y: number; + type: 'folder' | 'board'; + id: string; + name: string; + folderId?: string | null; + parentId?: string | null; + } | null>(null); const findBoardNameById = useCallback( (nodes: TreeNode[], id: string): string | null => { @@ -136,6 +163,64 @@ const ProjectDrawerContent: React.FC<{ [] ); + // 扁平化画板列表,用于 Shift 范围选择 + const flattenBoards = useCallback((nodes: TreeNode[]): Board[] => { + const result: Board[] = []; + for (const node of nodes) { + if (node.type === 'board') { + result.push((node as BoardTreeNode).data); + } else if (node.type === 'folder') { + const folderNode = node as FolderTreeNode; + if (folderNode.data.isExpanded && folderNode.children) { + result.push(...flattenBoards(folderNode.children)); + } + } + } + return result; + }, []); + + // 获取扁平化的画板列表 + const flatBoardList = useMemo(() => flattenBoards(tree), [flattenBoards, tree]); + + // 处理画板点击(支持 Shift 和 Ctrl/Cmd 多选) + const handleBoardClickWithSelection = useCallback( + (board: Board, e: React.MouseEvent) => { + const isShift = e.shiftKey; + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + if (isShift && lastClickedBoardId) { + // Shift+点击:范围选择 + const startIndex = flatBoardList.findIndex(b => b.id === lastClickedBoardId); + const endIndex = flatBoardList.findIndex(b => b.id === board.id); + if (startIndex !== -1 && endIndex !== -1) { + const [from, to] = startIndex < endIndex + ? [startIndex, endIndex] + : [endIndex, startIndex]; + const rangeIds = flatBoardList.slice(from, to + 1).map(b => b.id); + const newSelection = new Set(selectedBoardIds ?? []); + rangeIds.forEach(id => newSelection.add(id)); + onSelectionChange?.(newSelection); + } + } else if (isCtrlOrCmd) { + // Ctrl/Cmd+点击:切换单个选择 + const newSelection = new Set(selectedBoardIds ?? []); + if (newSelection.has(board.id)) { + newSelection.delete(board.id); + } else { + newSelection.add(board.id); + } + onSelectionChange?.(newSelection); + onLastClickedChange?.(board.id); + } else { + // 普通点击:清除选择,切换画板 + onSelectionChange?.(new Set()); + onLastClickedChange?.(board.id); + onBoardClick(board); + } + }, + [flatBoardList, lastClickedBoardId, selectedBoardIds, onSelectionChange, onLastClickedChange, onBoardClick] + ); + // 当有新建画板时自动进入编辑状态 useEffect(() => { if (autoEditBoardId) { @@ -164,6 +249,47 @@ const ProjectDrawerContent: React.FC<{ }; }, []); + // 关闭右键菜单 + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + // 点击其他地方关闭右键菜单 + useEffect(() => { + if (contextMenu?.visible) { + const handleClick = () => closeContextMenu(); + document.addEventListener('click', handleClick); + document.addEventListener('contextmenu', handleClick); + return () => { + document.removeEventListener('click', handleClick); + document.removeEventListener('contextmenu', handleClick); + }; + } + }, [contextMenu?.visible, closeContextMenu]); + + // 处理右键菜单 + const handleContextMenu = useCallback(( + e: React.MouseEvent, + type: 'folder' | 'board', + id: string, + name: string, + folderId?: string | null, + parentId?: string | null + ) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + type, + id, + name, + folderId, + parentId, + }); + }, []); + // Auto-select text when input is focused const handleInputFocus = useCallback( (_value: string, context: { e: React.FocusEvent }) => { @@ -385,6 +511,7 @@ const ProjectDrawerContent: React.FC<{ onDragOver={(e) => handleDragOver(e, folder.id, 'folder')} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, folder.id, 'folder', folder.parentId)} + onContextMenu={(e) => handleContextMenu(e, 'folder', folder.id, folder.name, undefined, folder.parentId)} > { + onClick={(e) => { if (!isEditing) { // Clear any existing timer if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); } + // Capture mouse event properties before timeout + const isShift = e.shiftKey; + const isCtrlOrCmd = e.ctrlKey || e.metaKey; // Delay board click to allow double-click to work clickTimerRef.current = setTimeout(() => { - onBoardClick(board); + handleBoardClickWithSelection(board, { + shiftKey: isShift, + ctrlKey: isCtrlOrCmd, + metaKey: isCtrlOrCmd + } as React.MouseEvent); }, 200); } }} @@ -536,6 +672,7 @@ const ProjectDrawerContent: React.FC<{ onDragOver={(e) => handleDragOver(e, board.id, 'board')} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, board.id, 'board', board.folderId)} + onContextMenu={(e) => handleContextMenu(e, 'board', board.id, board.name, board.folderId)} > @@ -607,6 +744,12 @@ const ProjectDrawerContent: React.FC<{ value: 'delete', prefixIcon: , }, + // 如果有多选,显示批量删除选项 + ...((selectedBoardIds?.size ?? 0) > 1 && isSelected ? [{ + content: `删除选中的 ${selectedBoardIds?.size ?? 0} 个画板`, + value: 'delete-selected', + prefixIcon: , + }] : []), ]} onClick={(data) => { if (data.value === 'copy') { @@ -615,6 +758,8 @@ const ProjectDrawerContent: React.FC<{ startEditing(board.id, board.name); } else if (data.value === 'delete') { onDelete('board', board.id, board.name); + } else if (data.value === 'delete-selected') { + onDeleteMultiple?.(Array.from(selectedBoardIds ?? [])); } else if (data.value === 'root') { onMoveBoard(board.id, null); } else if ( @@ -634,14 +779,144 @@ const ProjectDrawerContent: React.FC<{ ); }; + // 处理右键菜单点击 + const handleContextMenuAction = useCallback((action: string) => { + if (!contextMenu) return; + + const { type, id, name, folderId } = contextMenu; + + switch (action) { + case 'new-board': + onCreateBoard(id); + break; + case 'new-folder': + onCreateFolder(id); + break; + case 'copy': + onCopyBoard(id); + break; + case 'rename': + startEditing(id, name); + break; + case 'delete': + onDelete(type, id, name); + break; + case 'delete-selected': + onDeleteMultiple?.(Array.from(selectedBoardIds ?? [])); + break; + case 'move-root': + if (type === 'board') { + onMoveBoard(id, null); + } + break; + default: + // 移动到指定文件夹 + if (action.startsWith('move-')) { + const targetFolderId = action.replace('move-', ''); + if (type === 'board') { + onMoveBoard(id, targetFolderId); + } + } + break; + } + + closeContextMenu(); + }, [contextMenu, onCreateBoard, onCreateFolder, onCopyBoard, startEditing, onDelete, onDeleteMultiple, selectedBoardIds, onMoveBoard, closeContextMenu]); + + // 获取移动菜单选项 + const moveOptions = useMemo(() => { + if (!contextMenu || contextMenu.type !== 'board') return []; + return getFolderOptions(); + }, [contextMenu, getFolderOptions]); + return ( -
- {tree.map((node) => - node.type === 'folder' - ? renderFolderNode(node as FolderTreeNode) - : renderBoardNode(node as BoardTreeNode) + <> +
+ {tree.map((node) => + node.type === 'folder' + ? renderFolderNode(node as FolderTreeNode) + : renderBoardNode(node as BoardTreeNode) + )} +
+ + {/* 右键菜单 - 使用 Portal 渲染到 body 避免被容器截断 */} + {contextMenu?.visible && createPortal( +
+ {contextMenu.type === 'folder' ? ( + // 文件夹右键菜单 + <> +
handleContextMenuAction('new-board')}> + + 新建画板 +
+
handleContextMenuAction('new-folder')}> + + 新建文件夹 +
+
+
handleContextMenuAction('rename')}> + + 重命名 +
+
handleContextMenuAction('delete')}> + + 删除 +
+ + ) : ( + // 画板右键菜单 + <> +
handleContextMenuAction('copy')}> + + 复制 +
+
handleContextMenuAction('rename')}> + + 重命名 +
+
+
+ + 移动到 + +
+
+ {moveOptions.map((opt) => ( +
handleContextMenuAction(opt.value === 'root' ? 'move-root' : `move-${opt.value}`)} + > + {opt.content} +
+ ))} +
+
+
+
handleContextMenuAction('delete')}> + + 删除 +
+ {(selectedBoardIds?.size ?? 0) > 1 && selectedBoardIds?.has(contextMenu.id) && ( +
handleContextMenuAction('delete-selected')}> + + 删除选中的 {selectedBoardIds?.size} 个画板 +
+ )} + + )} +
, + document.body )} -
+ ); }; @@ -676,6 +951,11 @@ export const ProjectDrawer: React.FC = ({ id: string; name: string; } | null>(null); + // 多选状态 + const [selectedBoardIds, setSelectedBoardIds] = useState>(new Set()); + const [lastClickedBoardId, setLastClickedBoardId] = useState(null); + // 批量删除目标 + const [deleteMultipleTargets, setDeleteMultipleTargets] = useState([]); const [isExporting, setIsExporting] = useState(false); const [isImporting, setIsImporting] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); @@ -799,19 +1079,23 @@ export const ProjectDrawer: React.FC = ({ setDeleteTarget(null); }, [deleteTarget, deleteFolderWithContents]); - // Helper function to get the first available board from tree (excluding a specific id) + // Helper function to get the first available board from tree (excluding specific ids) const getFirstBoardFromTree = useCallback( - (nodes: TreeNode[], excludeId?: string): Board | null => { - for (const node of nodes) { - if (node.type === 'board' && node.data.id !== excludeId) { - return node.data; + (nodes: TreeNode[], ...excludeIds: string[]): Board | null => { + const excludeSet = new Set(excludeIds); + const findBoard = (nodes: TreeNode[]): Board | null => { + for (const node of nodes) { + if (node.type === 'board' && !excludeSet.has(node.data.id)) { + return node.data; + } + if (node.type === 'folder' && node.children) { + const board = findBoard(node.children); + if (board) return board; + } } - if (node.type === 'folder' && node.children) { - const board = getFirstBoardFromTree(node.children, excludeId); - if (board) return board; - } - } - return null; + return null; + }; + return findBoard(nodes); }, [] ); @@ -854,6 +1138,57 @@ export const ProjectDrawer: React.FC = ({ onBoardSwitch, ]); + // Handle delete multiple boards confirmation + const handleDeleteMultipleConfirm = useCallback((boardIds: string[]) => { + setDeleteMultipleTargets(boardIds); + setShowDeleteDialog(true); + }, []); + + // Handle delete multiple boards + const handleDeleteMultipleBoards = useCallback(async () => { + if (deleteMultipleTargets.length === 0) return; + + const deletingCurrentBoard = currentBoard && deleteMultipleTargets.includes(currentBoard.id); + let deletedCount = 0; + + for (const boardId of deleteMultipleTargets) { + const success = await deleteBoard(boardId); + if (success) deletedCount++; + } + + if (deletedCount > 0) { + MessagePlugin.success(`成功删除 ${deletedCount} 个画板`); + + // Clear selection + setSelectedBoardIds(new Set()); + + // If we deleted the current board, switch to the first available board + if (deletingCurrentBoard) { + const firstBoard = getFirstBoardFromTree(tree, ...deleteMultipleTargets); + if (firstBoard) { + if (onBeforeSwitch) { + await onBeforeSwitch(); + } + const switched = await switchBoard(firstBoard.id); + if (switched && onBoardSwitch) { + onBoardSwitch(switched); + } + } + } + } + setShowDeleteDialog(false); + setDeleteMultipleTargets([]); + }, [ + deleteMultipleTargets, + deleteBoard, + currentBoard, + tree, + getFirstBoardFromTree, + onBeforeSwitch, + switchBoard, + onBoardSwitch, + ]); + // Handle copy board const handleCopyBoard = useCallback( async (id: string) => { @@ -1090,13 +1425,38 @@ export const ProjectDrawer: React.FC = ({ // Search filter section const filterSection = ( - } - size="small" - /> +
+ } + size="small" + /> + {selectedBoardIds.size > 0 && ( +
+ + 已选择 {selectedBoardIds.size} 个画板 + + + +
+ )} +
); // Footer with import/export buttons @@ -1167,12 +1527,17 @@ export const ProjectDrawer: React.FC = ({ onCreateFolder={handleCreateFolder} onRename={handleRename} onDelete={handleDeleteConfirm} + onDeleteMultiple={handleDeleteMultipleConfirm} onCopyBoard={handleCopyBoard} onMoveBoard={handleMoveBoard} onMoveFolder={handleMoveFolder} toggleFolderExpanded={toggleFolderExpanded} autoEditBoardId={autoEditBoardId} onAutoEditDone={() => setAutoEditBoardId(null)} + selectedBoardIds={selectedBoardIds} + onSelectionChange={setSelectedBoardIds} + lastClickedBoardId={lastClickedBoardId} + onLastClickedChange={setLastClickedBoardId} /> )} @@ -1181,7 +1546,11 @@ export const ProjectDrawer: React.FC = ({ setShowDeleteDialog(false)} + onClose={() => { + setShowDeleteDialog(false); + setDeleteTarget(null); + setDeleteMultipleTargets([]); + }} footer={ deleteTarget?.type === 'folder' ? (
@@ -1201,26 +1570,42 @@ export const ProjectDrawer: React.FC = ({ ) : undefined } onConfirm={ - deleteTarget?.type === 'board' ? handleDeleteBoard : undefined + deleteMultipleTargets.length > 0 + ? handleDeleteMultipleBoards + : deleteTarget?.type === 'board' + ? handleDeleteBoard + : undefined + } + confirmBtn={ + deleteMultipleTargets.length > 0 || deleteTarget?.type === 'board' + ? '删除' + : undefined + } + cancelBtn={ + deleteMultipleTargets.length > 0 || deleteTarget?.type === 'board' + ? '取消' + : undefined } - confirmBtn={deleteTarget?.type === 'board' ? '删除' : undefined} - cancelBtn={deleteTarget?.type === 'board' ? '取消' : undefined} > -

- 确定要删除 {deleteTarget?.type === 'folder' ? '文件夹' : '画板'} " - {deleteTarget?.name}" 吗? - {deleteTarget?.type === 'folder' && ( - - 仅删目录时,文件夹内的所有内容将被移动到根目录。 - - )} -

+ {deleteMultipleTargets.length > 0 ? ( +

确定要删除选中的 {deleteMultipleTargets.length} 个画板吗?此操作不可撤销。

+ ) : ( +

+ 确定要删除 {deleteTarget?.type === 'folder' ? '文件夹' : '画板'} " + {deleteTarget?.name}" 吗? + {deleteTarget?.type === 'folder' && ( + + 仅删目录时,文件夹内的所有内容将被移动到根目录。 + + )} +

+ )}
{/* Import progress dialog */} diff --git a/packages/drawnix/src/components/project-drawer/project-drawer.scss b/packages/drawnix/src/components/project-drawer/project-drawer.scss index 84bac4b5..003e6309 100644 --- a/packages/drawnix/src/components/project-drawer/project-drawer.scss +++ b/packages/drawnix/src/components/project-drawer/project-drawer.scss @@ -27,6 +27,28 @@ padding: $spacing-sm 0; } + &__filter-section { + display: flex; + flex-direction: column; + gap: $spacing-sm; + } + + &__selection-bar { + display: flex; + align-items: center; + gap: $spacing-xs; + padding: $spacing-xs $spacing-sm; + background: rgba(243, 156, 18, 0.12); + border-radius: 4px; + font-size: 12px; + } + + &__selection-count { + flex: 1; + color: #F39C12; + font-weight: 500; + } + &__footer-actions { display: flex; gap: $spacing-sm; @@ -110,6 +132,18 @@ } } + &--selected { + background: rgba(243, 156, 18, 0.12); + + &:hover { + background: rgba(243, 156, 18, 0.18); + } + } + + &--active#{&}--selected { + background: rgba(243, 156, 18, 0.15); + } + &--folder { font-weight: 500; } @@ -144,7 +178,7 @@ } &--drag-over-inside { - background: var(--td-brand-color-light); + background: rgba(243, 156, 18, 0.12); outline: 2px solid var(--td-brand-color); outline-offset: -2px; } @@ -273,6 +307,77 @@ } } +// ============================================================================ +// Context Menu +// ============================================================================ + +.project-drawer-context-menu { + min-width: 160px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-level-2-color); + border-radius: 6px; + box-shadow: var(--td-shadow-2); + padding: 4px 0; + + &__item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + color: var(--td-text-color-primary); + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background: var(--td-bg-color-container-hover); + } + + &--danger { + color: var(--td-error-color); + + &:hover { + background: rgba(233, 30, 99, 0.08); + } + } + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } + } + + &__divider { + height: 1px; + background: var(--td-border-level-1-color); + margin: 4px 0; + } + + &__submenu { + position: relative; + + &:hover > .project-drawer-context-menu__submenu-content { + display: block; + } + } + + &__submenu-content { + display: none; + position: absolute; + left: 100%; + top: -4px; + min-width: 140px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-level-2-color); + border-radius: 6px; + box-shadow: var(--td-shadow-2); + padding: 4px 0; + max-height: 300px; + overflow-y: auto; + } +} + // ============================================================================ // Responsive // ============================================================================ diff --git a/packages/drawnix/src/components/sync-settings/RecycleBin.tsx b/packages/drawnix/src/components/sync-settings/RecycleBin.tsx index 5d3e123b..b262bd68 100644 --- a/packages/drawnix/src/components/sync-settings/RecycleBin.tsx +++ b/packages/drawnix/src/components/sync-settings/RecycleBin.tsx @@ -11,10 +11,16 @@ import { RollbackIcon, ChevronRightIcon, ChevronDownIcon, + LinkIcon, } from 'tdesign-icons-react'; import { syncEngine } from '../../services/github-sync'; import type { DeletedItems } from '../../services/github-sync/types'; +/** 构建画板文件的 Gist URL */ +function getBoardGistUrl(gistId: string, boardId: string): string { + return `https://gist.github.com/${gistId}#file-board_${boardId.replace(/-/g, '-')}-json`; +} + interface RecycleBinProps { isConnected: boolean; onRefresh?: () => void; @@ -52,6 +58,7 @@ export function RecycleBin({ isConnected, onRefresh }: RecycleBinProps) { const [isLoading, setIsLoading] = useState(false); const [restoringId, setRestoringId] = useState(null); const [deletingId, setDeletingId] = useState(null); + const [gistId, setGistId] = useState(null); // 确认对话框状态 const [deleteConfirmItem, setDeleteConfirmItem] = useState<{ @@ -69,8 +76,12 @@ export function RecycleBin({ isConnected, onRefresh }: RecycleBinProps) { setIsLoading(true); try { - const items = await syncEngine.getDeletedItems(); + const [items, config] = await Promise.all([ + syncEngine.getDeletedItems(), + syncEngine.getConfig(), + ]); setDeletedItems(items); + setGistId(config.gistId || null); } catch (err) { console.error('[RecycleBin] Failed to load deleted items:', err); MessagePlugin.error('加载回收站失败'); @@ -212,6 +223,17 @@ export function RecycleBin({ isConnected, onRefresh }: RecycleBinProps) {
+ {gistId && ( + + )} + {hasStoredPassword && ( + + )}