docs: 添加 CSS 变量优先级和弹出菜单截断规则
- CLAUDE.md: 更新 createPortal 规则说明,新增组件内颜色使用规范 - CODING_RULES.md: 添加详细的 TDesign CSS 变量优先级问题说明 - CODING_RULES.md: 添加弹出菜单被 overflow 截断的解决方案
This commit is contained in:
1 parent
a36176ba97
commit
8ecc8e7571
25 files changed
+1709
-333
No files matched your search
@@ -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={Icon}`
|
||||
@@ -211,6 +211,7 @@ Service Worker (后台执行)
|
||||
- **可点击容器**:用 `pointer-events: none` + 父容器 `onClick` 扩大交互区域
|
||||
- **筛选与选中联动**:选中计数、操作都应基于筛选后的结果
|
||||
- **全局配色统一**:在 `tdesign-theme.scss` 中集中覆盖第三方组件样式
|
||||
- **组件内颜色**:TDesign CSS 变量可能被覆盖,组件 scss 中应直接使用具体颜色值(如 `rgba(243, 156, 18, 0.12)`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 '<span class="json-null">null</span>';
|
||||
}
|
||||
if (typeof obj === 'boolean') {
|
||||
return `<span class="json-boolean">${obj}</span>`;
|
||||
}
|
||||
if (typeof obj === 'number') {
|
||||
return `<span class="json-number">${obj}</span>`;
|
||||
}
|
||||
if (typeof obj === 'string') {
|
||||
// 截断过长的字符串
|
||||
const displayStr = obj.length > 500 ? obj.substring(0, 500) + '...' : obj;
|
||||
return `<span class="json-string">"${escapeHtml(displayStr)}"</span>`;
|
||||
}
|
||||
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} <span class="json-key">"${escapeHtml(key)}"</span>: ${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 = `
|
||||
<div class="log-header">
|
||||
${hasDetails ? `<span class="log-toggle" title="展开/收起详情"><span class="arrow">▶</span></span>` : '<span style="width: 16px; display: inline-block;"></span>'}
|
||||
<span class="log-time">${formatTime(log.timestamp)}</span>
|
||||
<span class="log-status ${levelStatusClass}">${level.toUpperCase()}</span>
|
||||
<span class="log-url" title="${escapeHtml(log.logMessage || '')}">${escapeHtml(messagePreview)}</span>
|
||||
<span class="log-url" title="${escapeHtml(displayMessage)}">${escapeHtml(headerMessage)}</span>
|
||||
</div>
|
||||
${hasDetails ? `
|
||||
<div class="log-details">
|
||||
${log.logMessage ? `
|
||||
<div class="detail-section">
|
||||
<h4>完整消息</h4>
|
||||
<pre>${escapeHtml(log.logMessage)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.logSource ? `
|
||||
<div class="detail-section">
|
||||
<h4>完整消息</h4>
|
||||
<pre class="console-message-pre">${escapeHtml(displayMessage)}</pre>
|
||||
</div>
|
||||
${hasSource ? `
|
||||
<div class="detail-section">
|
||||
<h4>来源</h4>
|
||||
<pre>${escapeHtml(log.logSource)}</pre>
|
||||
<pre class="console-source-pre">${escapeHtml(source)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.url ? `
|
||||
@@ -78,7 +171,13 @@ export function createConsoleEntry(log, isExpanded = false, onToggle = null) {
|
||||
${hasStack ? `
|
||||
<div class="detail-section">
|
||||
<h4>堆栈</h4>
|
||||
<pre style="color: var(--error-color);">${formatStack(log.logStack)}</pre>
|
||||
<pre class="console-stack-pre" style="color: var(--error-color);">${formatStack(stack)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${hasExtra ? `
|
||||
<div class="detail-section">
|
||||
<h4>其他信息</h4>
|
||||
<pre class="json-highlight">${formatJsonHtml(parsedMsg.extra)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
"添加\"不再提示\"按钮到灵感创意板块"
|
||||
|
||||
@@ -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');
|
||||
|
||||
+171
-155
@@ -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<string, CompletedRequestEntry>();
|
||||
const COMPLETED_REQUEST_CACHE_TTL = 30 * 1000;
|
||||
|
||||
interface VideoRequestEntry {
|
||||
promise: Promise<Blob | null>;
|
||||
promise: Promise<Blob | null | symbol>; // symbol = VIDEO_LOAD_ERROR 表示下载失败
|
||||
timestamp: number;
|
||||
count: number;
|
||||
requestId: string;
|
||||
@@ -476,13 +495,15 @@ async function saveConsoleLogToDB(logEntry: DebugLogEntry): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 从 IndexedDB 加载所有控制台日志
|
||||
// 从 IndexedDB 加载控制台日志(仅返回 7 天内,加载前先清理过期)
|
||||
async function loadConsoleLogsFromDB(): Promise<DebugLogEntry[]> {
|
||||
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<DebugLogEntry[]> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 控制台日志内存条数上限(避免无限增长)
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinel:视频下载失败时返回此值,避免 throw 导致 Uncaught (in promise)
|
||||
const VIDEO_LOAD_ERROR = Symbol('VIDEO_LOAD_ERROR');
|
||||
|
||||
// 处理视频请求,支持 Range 请求以实现视频 seek 功能
|
||||
async function handleVideoRequest(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
@@ -2958,7 +2979,16 @@ async function handleVideoRequest(request: Request): Promise<Response> {
|
||||
// 等待视频下载完成
|
||||
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<Response> {
|
||||
|
||||
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<Response> {
|
||||
// 等待视频下载完成
|
||||
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<Response> {
|
||||
|
||||
// 使用下载的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<Response> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void> | 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 有效,故不监听
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="drawer-content" style={{ overflow: 'auto' }}>
|
||||
{/* 内容 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="context-menu"
|
||||
style={{ position: 'fixed', left: x, top: y }}
|
||||
>
|
||||
{/* 菜单项 */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
✅ **正确示例**:
|
||||
```tsx
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
return (
|
||||
<div className="drawer-content" style={{ overflow: 'auto' }}>
|
||||
{/* 内容 */}
|
||||
{/* 使用 Portal 渲染到 body,避免被截断 */}
|
||||
{contextMenu && createPortal(
|
||||
<div
|
||||
className="context-menu"
|
||||
style={{ position: 'fixed', left: x, top: y, zIndex: 10000 }}
|
||||
>
|
||||
{/* 菜单项 */}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
**原因**: 即使使用 `position: fixed`,如果元素在 DOM 树中位于设置了 `overflow: hidden` 的容器内部,该容器仍然会裁切超出其边界的内容。使用 `createPortal` 将弹出层渲染到 `document.body`,使其脱离原有容器的裁切上下文。
|
||||
|
||||
**适用场景**:
|
||||
- 右键菜单
|
||||
- 自定义下拉菜单
|
||||
- 悬浮预览框
|
||||
- 任何需要显示在容器边界外的浮层
|
||||
|
||||
### Git 提交规范
|
||||
- 格式: `<type>(<scope>): <subject>`
|
||||
- 类型: `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<void> {
|
||||
// 用户点击确认后,弹窗卡住等待网络请求
|
||||
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<void> {
|
||||
// 异步同步到远程(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)可能导致旧的订阅被替换
|
||||
- 关键业务逻辑不应依赖于"某处可能存在的订阅者"
|
||||
|
||||
@@ -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<string>;
|
||||
/** 设置多选状态 */
|
||||
onSelectionChange: (ids: Set<string>) => 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<string | null>(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<HTMLInputElement> }) => {
|
||||
@@ -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)}
|
||||
>
|
||||
<span
|
||||
className="project-drawer-node__expand"
|
||||
@@ -508,6 +635,7 @@ const ProjectDrawerContent: React.FC<{
|
||||
const isActive = board.id === currentBoard?.id;
|
||||
const isEditing = editingId === board.id;
|
||||
const isDragging = dragData?.id === board.id;
|
||||
const isSelected = selectedBoardIds?.has(board.id) ?? false;
|
||||
const paddingLeft = level * 16 + 8;
|
||||
|
||||
return (
|
||||
@@ -515,19 +643,27 @@ const ProjectDrawerContent: React.FC<{
|
||||
<div
|
||||
className={`project-drawer-node__row
|
||||
${isActive ? 'project-drawer-node__row--active' : ''}
|
||||
${isSelected ? 'project-drawer-node__row--selected' : ''}
|
||||
${isDragging ? 'project-drawer-node__row--dragging' : ''}
|
||||
${getDragOverClass(board.id)}`}
|
||||
style={{ paddingLeft }}
|
||||
draggable={!isEditing}
|
||||
onClick={() => {
|
||||
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)}
|
||||
>
|
||||
<span className="project-drawer-node__expand"></span>
|
||||
|
||||
@@ -607,6 +744,12 @@ const ProjectDrawerContent: React.FC<{
|
||||
value: 'delete',
|
||||
prefixIcon: <DeleteIcon />,
|
||||
},
|
||||
// 如果有多选,显示批量删除选项
|
||||
...((selectedBoardIds?.size ?? 0) > 1 && isSelected ? [{
|
||||
content: `删除选中的 ${selectedBoardIds?.size ?? 0} 个画板`,
|
||||
value: 'delete-selected',
|
||||
prefixIcon: <DeleteIcon />,
|
||||
}] : []),
|
||||
]}
|
||||
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 (
|
||||
<div className="project-drawer__tree">
|
||||
{tree.map((node) =>
|
||||
node.type === 'folder'
|
||||
? renderFolderNode(node as FolderTreeNode)
|
||||
: renderBoardNode(node as BoardTreeNode)
|
||||
<>
|
||||
<div className="project-drawer__tree">
|
||||
{tree.map((node) =>
|
||||
node.type === 'folder'
|
||||
? renderFolderNode(node as FolderTreeNode)
|
||||
: renderBoardNode(node as BoardTreeNode)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 - 使用 Portal 渲染到 body 避免被容器截断 */}
|
||||
{contextMenu?.visible && createPortal(
|
||||
<div
|
||||
className="project-drawer-context-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
{contextMenu.type === 'folder' ? (
|
||||
// 文件夹右键菜单
|
||||
<>
|
||||
<div className="project-drawer-context-menu__item" onClick={() => handleContextMenuAction('new-board')}>
|
||||
<AddIcon />
|
||||
<span>新建画板</span>
|
||||
</div>
|
||||
<div className="project-drawer-context-menu__item" onClick={() => handleContextMenuAction('new-folder')}>
|
||||
<FolderAddIcon />
|
||||
<span>新建文件夹</span>
|
||||
</div>
|
||||
<div className="project-drawer-context-menu__divider" />
|
||||
<div className="project-drawer-context-menu__item" onClick={() => handleContextMenuAction('rename')}>
|
||||
<EditIcon />
|
||||
<span>重命名</span>
|
||||
</div>
|
||||
<div className="project-drawer-context-menu__item project-drawer-context-menu__item--danger" onClick={() => handleContextMenuAction('delete')}>
|
||||
<DeleteIcon />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// 画板右键菜单
|
||||
<>
|
||||
<div className="project-drawer-context-menu__item" onClick={() => handleContextMenuAction('copy')}>
|
||||
<FileCopyIcon />
|
||||
<span>复制</span>
|
||||
</div>
|
||||
<div className="project-drawer-context-menu__item" onClick={() => handleContextMenuAction('rename')}>
|
||||
<EditIcon />
|
||||
<span>重命名</span>
|
||||
</div>
|
||||
<div className="project-drawer-context-menu__submenu">
|
||||
<div className="project-drawer-context-menu__item">
|
||||
<MoveIcon />
|
||||
<span>移动到</span>
|
||||
<ChevronRightIcon style={{ marginLeft: 'auto' }} />
|
||||
</div>
|
||||
<div className="project-drawer-context-menu__submenu-content">
|
||||
{moveOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className="project-drawer-context-menu__item"
|
||||
onClick={() => handleContextMenuAction(opt.value === 'root' ? 'move-root' : `move-${opt.value}`)}
|
||||
>
|
||||
<span>{opt.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-drawer-context-menu__divider" />
|
||||
<div className="project-drawer-context-menu__item project-drawer-context-menu__item--danger" onClick={() => handleContextMenuAction('delete')}>
|
||||
<DeleteIcon />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
{(selectedBoardIds?.size ?? 0) > 1 && selectedBoardIds?.has(contextMenu.id) && (
|
||||
<div className="project-drawer-context-menu__item project-drawer-context-menu__item--danger" onClick={() => handleContextMenuAction('delete-selected')}>
|
||||
<DeleteIcon />
|
||||
<span>删除选中的 {selectedBoardIds?.size} 个画板</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -676,6 +951,11 @@ export const ProjectDrawer: React.FC<ProjectDrawerProps> = ({
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
// 多选状态
|
||||
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(new Set());
|
||||
const [lastClickedBoardId, setLastClickedBoardId] = useState<string | null>(null);
|
||||
// 批量删除目标
|
||||
const [deleteMultipleTargets, setDeleteMultipleTargets] = useState<string[]>([]);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
@@ -799,19 +1079,23 @@ export const ProjectDrawer: React.FC<ProjectDrawerProps> = ({
|
||||
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<ProjectDrawerProps> = ({
|
||||
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<ProjectDrawerProps> = ({
|
||||
|
||||
// Search filter section
|
||||
const filterSection = (
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
prefixIcon={<SearchIcon />}
|
||||
size="small"
|
||||
/>
|
||||
<div className="project-drawer__filter-section">
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
prefixIcon={<SearchIcon />}
|
||||
size="small"
|
||||
/>
|
||||
{selectedBoardIds.size > 0 && (
|
||||
<div className="project-drawer__selection-bar">
|
||||
<span className="project-drawer__selection-count">
|
||||
已选择 {selectedBoardIds.size} 个画板
|
||||
</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
theme="danger"
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => handleDeleteMultipleConfirm(Array.from(selectedBoardIds))}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => setSelectedBoardIds(new Set())}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Footer with import/export buttons
|
||||
@@ -1167,12 +1527,17 @@ export const ProjectDrawer: React.FC<ProjectDrawerProps> = ({
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</BaseDrawer>
|
||||
@@ -1181,7 +1546,11 @@ export const ProjectDrawer: React.FC<ProjectDrawerProps> = ({
|
||||
<Dialog
|
||||
visible={showDeleteDialog}
|
||||
header="确认删除"
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
onClose={() => {
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTarget(null);
|
||||
setDeleteMultipleTargets([]);
|
||||
}}
|
||||
footer={
|
||||
deleteTarget?.type === 'folder' ? (
|
||||
<div className="project-drawer__delete-dialog-footer">
|
||||
@@ -1201,26 +1570,42 @@ export const ProjectDrawer: React.FC<ProjectDrawerProps> = ({
|
||||
) : 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}
|
||||
>
|
||||
<p>
|
||||
确定要删除 {deleteTarget?.type === 'folder' ? '文件夹' : '画板'} "
|
||||
{deleteTarget?.name}" 吗?
|
||||
{deleteTarget?.type === 'folder' && (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--td-text-color-secondary)',
|
||||
display: 'block',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
仅删目录时,文件夹内的所有内容将被移动到根目录。
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{deleteMultipleTargets.length > 0 ? (
|
||||
<p>确定要删除选中的 {deleteMultipleTargets.length} 个画板吗?此操作不可撤销。</p>
|
||||
) : (
|
||||
<p>
|
||||
确定要删除 {deleteTarget?.type === 'folder' ? '文件夹' : '画板'} "
|
||||
{deleteTarget?.name}" 吗?
|
||||
{deleteTarget?.type === 'folder' && (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--td-text-color-secondary)',
|
||||
display: 'block',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
仅删目录时,文件夹内的所有内容将被移动到根目录。
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* Import progress dialog */}
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [gistId, setGistId] = useState<string | null>(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) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="sync-settings__recycle-item-actions">
|
||||
{gistId && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
icon={<LinkIcon />}
|
||||
onClick={() => window.open(getBoardGistUrl(gistId, board.id), '_blank')}
|
||||
title="在 GitHub 查看文件"
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
|
||||
@@ -113,8 +113,23 @@ export function SyncSettings({ visible, onClose }: SyncSettingsProps) {
|
||||
// 加密密码状态
|
||||
const [customPassword, setCustomPassword] = useState('');
|
||||
const [hasStoredPassword, setHasStoredPassword] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [storedPassword, setStoredPassword] = useState(''); // 完整的已存储密码
|
||||
const [showStoredPassword, setShowStoredPassword] = useState(false); // 是否显示完整密码
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
|
||||
/** 创建掩码密码(显示前后各2位) */
|
||||
const maskPassword = (password: string): string => {
|
||||
if (password.length <= 4) {
|
||||
return '*'.repeat(password.length);
|
||||
}
|
||||
const prefix = password.slice(0, 2);
|
||||
const suffix = password.slice(-2);
|
||||
const masked = '*'.repeat(Math.min(password.length - 4, 6));
|
||||
return `${prefix}${masked}${suffix}`;
|
||||
};
|
||||
|
||||
/** 获取显示的密码(根据显示/隐藏状态) */
|
||||
const displayPassword = showStoredPassword ? storedPassword : maskPassword(storedPassword);
|
||||
|
||||
// 加载 Gist 列表
|
||||
const loadGists = useCallback(async () => {
|
||||
@@ -142,7 +157,11 @@ export function SyncSettings({ visible, onClose }: SyncSettingsProps) {
|
||||
// 加载已存储的密码状态
|
||||
useEffect(() => {
|
||||
if (visible && isConnected) {
|
||||
syncPasswordService.hasPassword().then(setHasStoredPassword);
|
||||
syncPasswordService.getPassword().then((password) => {
|
||||
setHasStoredPassword(!!password);
|
||||
setStoredPassword(password || '');
|
||||
setShowStoredPassword(false); // 重置为隐藏状态
|
||||
});
|
||||
}
|
||||
}, [visible, isConnected]);
|
||||
|
||||
@@ -152,13 +171,14 @@ export function SyncSettings({ visible, onClose }: SyncSettingsProps) {
|
||||
try {
|
||||
await syncPasswordService.savePassword(customPassword);
|
||||
setHasStoredPassword(!!customPassword);
|
||||
setStoredPassword(customPassword || '');
|
||||
setShowStoredPassword(false);
|
||||
if (customPassword) {
|
||||
MessagePlugin.success('加密密码已保存');
|
||||
} else {
|
||||
MessagePlugin.info('已恢复使用默认加密');
|
||||
}
|
||||
setCustomPassword('');
|
||||
setShowPassword(false);
|
||||
} catch (err) {
|
||||
MessagePlugin.error('保存密码失败');
|
||||
} finally {
|
||||
@@ -723,23 +743,27 @@ export function SyncSettings({ visible, onClose }: SyncSettingsProps) {
|
||||
</p>
|
||||
<div className="sync-settings__password-input-row">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type="text"
|
||||
className="sync-settings__password-input"
|
||||
value={customPassword}
|
||||
onChange={(e) => setCustomPassword(e.target.value)}
|
||||
placeholder={hasStoredPassword ? '输入新密码以更换' : '设置自定义加密密码(可选)'}
|
||||
placeholder={hasStoredPassword
|
||||
? `当前: ${displayPassword},输入新密码以更换`
|
||||
: '设置自定义加密密码(可选)'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? '隐藏' : '显示'}
|
||||
</Button>
|
||||
{hasStoredPassword && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => setShowStoredPassword(!showStoredPassword)}
|
||||
>
|
||||
{showStoredPassword ? '隐藏' : '显示'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
theme="primary"
|
||||
variant={hasStoredPassword ? 'outline' : 'base'}
|
||||
variant="base"
|
||||
size="small"
|
||||
loading={isSavingPassword}
|
||||
onClick={handleSavePassword}
|
||||
|
||||
@@ -81,6 +81,7 @@ export const VirtualTaskList: React.FC<VirtualTaskListProps> = ({
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const lastLoadMoreTimeRef = useRef<number>(0);
|
||||
// 惰性初始化:小屏幕默认使用紧凑布局(用屏幕宽度作为初始估计)
|
||||
const [internalIsCompact, setInternalIsCompact] = useState(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < COMPACT_LAYOUT_THRESHOLD) {
|
||||
@@ -131,8 +132,13 @@ export const VirtualTaskList: React.FC<VirtualTaskListProps> = ({
|
||||
// 检查是否滚动到底部附近(距离底部 300px 时触发加载)
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRoot;
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
|
||||
// 防抖:距上次加载不足 500ms 不重复触发,避免滚动时 loadMore 级联导致崩溃
|
||||
const now = Date.now();
|
||||
if (now - lastLoadMoreTimeRef.current < 500) return;
|
||||
|
||||
if (distanceToBottom < 300 && hasMore && !isLoadingMore) {
|
||||
lastLoadMoreTimeRef.current = now;
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ export const VideoComponent: React.FC<VideoComponentProps> = ({
|
||||
const handleError = () => {
|
||||
setIsLoading(false);
|
||||
setVideoError(true);
|
||||
console.warn('Failed to load video:', url);
|
||||
};
|
||||
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
|
||||
@@ -287,9 +287,16 @@ export function GitHubSyncProvider({ children }: GitHubSyncProviderProps) {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
// 监听工作区变更,自动标记需要同步
|
||||
const workspaceSubscription = workspaceService.observeEvents().subscribe((event) => {
|
||||
console.log('[GitHubSync] Setting up workspace event subscription...');
|
||||
const workspaceSubscription = workspaceService.observeEvents().subscribe({
|
||||
next: (event) => {
|
||||
console.log(`[GitHubSync] Workspace event received: ${event.type}`, event.payload);
|
||||
|
||||
// 只在已连接时才标记变更
|
||||
if (!tokenService.hasToken()) return;
|
||||
if (!tokenService.hasToken()) {
|
||||
console.log(`[GitHubSync] No token, skipping event ${event.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 监听画板和文件夹的变更事件
|
||||
const syncTriggerEvents = [
|
||||
@@ -304,8 +311,41 @@ export function GitHubSyncProvider({ children }: GitHubSyncProviderProps) {
|
||||
if (syncTriggerEvents.includes(event.type)) {
|
||||
console.log(`[GitHubSync] Workspace changed: ${event.type}, marking dirty...`);
|
||||
syncEngine.markDirty();
|
||||
// 记录本地删除的画板,并立即同步到远程回收站
|
||||
if (event.type === 'boardDeleted') {
|
||||
const boardId = (event.payload as { id?: string })?.id;
|
||||
console.log(`[GitHubSync] boardDeleted event, payload:`, event.payload, 'boardId:', boardId);
|
||||
if (boardId && typeof boardId === 'string') {
|
||||
console.log(`[GitHubSync] Syncing board deletion to remote: ${boardId}`);
|
||||
// 先记录本地删除,然后立即同步到远程
|
||||
syncEngine.recordLocalDeletion(boardId).then(() => {
|
||||
// 立即同步删除到远程回收站
|
||||
return syncEngine.syncBoardDeletion(boardId);
|
||||
}).then((result) => {
|
||||
if (result.success) {
|
||||
console.log(`[GitHubSync] Board ${boardId} moved to remote recycle bin`);
|
||||
} else {
|
||||
console.warn(`[GitHubSync] Failed to sync deletion to remote: ${result.error}`);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(`[GitHubSync] Failed to sync board deletion: ${boardId}`, err);
|
||||
});
|
||||
} else {
|
||||
console.warn(`[GitHubSync] boardDeleted event has invalid payload:`, event.payload);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[GitHubSync] Event ${event.type} does not trigger sync`);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('[GitHubSync] Workspace event subscription error:', err);
|
||||
},
|
||||
complete: () => {
|
||||
console.log('[GitHubSync] Workspace event subscription completed');
|
||||
}
|
||||
});
|
||||
console.log('[GitHubSync] Workspace event subscription established');
|
||||
|
||||
return () => {
|
||||
syncEngine.removeStatusListener(handleStatusChange);
|
||||
|
||||
@@ -38,7 +38,6 @@ export const Video: React.FC<VideoProps> = (props: VideoProps) => {
|
||||
const handleError = () => {
|
||||
setIsLoading(false);
|
||||
setVideoError(true);
|
||||
console.warn('Failed to load video:', url);
|
||||
};
|
||||
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
|
||||
@@ -642,9 +642,15 @@ class DataSerializer {
|
||||
});
|
||||
|
||||
console.log('[DataSerializer] Calling restoreTasks with', processedTasks.length, 'tasks');
|
||||
await swTaskQueueService.restoreTasks(processedTasks);
|
||||
tasksApplied = processedTasks.length;
|
||||
console.log('[DataSerializer] Tasks restored:', tasksApplied);
|
||||
try {
|
||||
await swTaskQueueService.restoreTasks(processedTasks);
|
||||
tasksApplied = processedTasks.length;
|
||||
console.log('[DataSerializer] Tasks restored:', tasksApplied);
|
||||
} catch (err) {
|
||||
// SW 可能未初始化或超时,跳过任务恢复但继续同步
|
||||
console.warn('[DataSerializer] Failed to restore tasks (SW may not be ready):', err);
|
||||
tasksApplied = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[DataSerializer] No tasks to apply');
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
/** 同步配置存储键 */
|
||||
const SYNC_CONFIG_KEY = 'github_sync_config';
|
||||
|
||||
/** 本地已删除、尚未同步到远程的画板(ID -> 删除时间戳),下载远程时按时间戳判断是否恢复 */
|
||||
const LOCAL_DELETIONS_PENDING_KEY = 'github_sync_local_deletions_pending';
|
||||
|
||||
/** 同步状态变更监听器 */
|
||||
type SyncStatusListener = (status: SyncStatus, message?: string) => void;
|
||||
|
||||
@@ -410,8 +413,50 @@ class SyncEngine {
|
||||
result.uploaded.tasks = localData.tasks.completedTasks.length;
|
||||
result.success = true;
|
||||
}
|
||||
} else if (remoteManifest) {
|
||||
console.log('[SyncEngine] Has remoteManifest, comparing changes...');
|
||||
} else if (config.gistId) {
|
||||
console.log('[SyncEngine] Has gistId, comparing changes...');
|
||||
|
||||
if (!remoteManifest) {
|
||||
console.warn('[SyncEngine] No remote manifest found, will perform full upload');
|
||||
// 没有远程 manifest,执行完整上传
|
||||
const { cryptoService } = await import('./crypto-service');
|
||||
const filesToUpdate: Record<string, string> = {};
|
||||
|
||||
// 上传所有画板
|
||||
for (const [boardId, board] of localData.boards) {
|
||||
const boardJson = JSON.stringify(board);
|
||||
filesToUpdate[SYNC_FILES.boardFile(boardId)] = await cryptoService.encrypt(boardJson, config.gistId);
|
||||
localData.manifest.boards[boardId] = {
|
||||
name: board.name,
|
||||
updatedAt: board.updatedAt,
|
||||
checksum: dataSerializer.calculateBoardChecksum(board),
|
||||
};
|
||||
}
|
||||
|
||||
// 更新 manifest
|
||||
localData.manifest.updatedAt = Date.now();
|
||||
const manifestWithFlag = { ...localData.manifest, encrypted: true };
|
||||
filesToUpdate[SYNC_FILES.MANIFEST] = JSON.stringify(manifestWithFlag, null, 2);
|
||||
|
||||
// 加密 workspace、prompts、tasks
|
||||
const workspaceJson = JSON.stringify(localData.workspace);
|
||||
filesToUpdate[SYNC_FILES.WORKSPACE] = await cryptoService.encrypt(workspaceJson, config.gistId);
|
||||
|
||||
const promptsJson = JSON.stringify(localData.prompts);
|
||||
filesToUpdate[SYNC_FILES.PROMPTS] = await cryptoService.encrypt(promptsJson, config.gistId);
|
||||
|
||||
const tasksJson = JSON.stringify(localData.tasks);
|
||||
filesToUpdate[SYNC_FILES.TASKS] = await cryptoService.encrypt(tasksJson, config.gistId);
|
||||
|
||||
await gitHubApiService.updateGistFiles(filesToUpdate);
|
||||
result.uploaded.boards = localData.boards.size;
|
||||
result.success = true;
|
||||
|
||||
await this.saveConfig({
|
||||
lastSyncTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
console.log('[SyncEngine] Has remoteManifest, comparing changes...');
|
||||
// 比较变更
|
||||
const changes = dataSerializer.compareBoardChanges(
|
||||
localData.boards,
|
||||
@@ -426,6 +471,9 @@ class SyncEngine {
|
||||
});
|
||||
|
||||
// 检测本地删除的画板(用于更新远程 tombstone)
|
||||
console.log('[SyncEngine] Detecting local deletions...');
|
||||
console.log('[SyncEngine] Local boards:', Array.from(localData.boards.keys()));
|
||||
console.log('[SyncEngine] Remote manifest boards:', Object.keys(remoteManifest.boards));
|
||||
const localDeletions = dataSerializer.detectDeletions(
|
||||
localData,
|
||||
remoteManifest,
|
||||
@@ -433,6 +481,7 @@ class SyncEngine {
|
||||
);
|
||||
console.log('[SyncEngine] Local deletions detected:', {
|
||||
boards: localDeletions.deletedBoards.length,
|
||||
deletedBoardIds: localDeletions.deletedBoards.map(b => b.id),
|
||||
});
|
||||
|
||||
// 安全检查:处理远程要求删除本地画板的情况
|
||||
@@ -673,15 +722,17 @@ class SyncEngine {
|
||||
// 注意:这里只是上传了 tombstone,不是删除本地数据
|
||||
// 上传的 tombstone 数量可以在日志中看到
|
||||
console.log('[SyncEngine] Uploaded tombstones for', localDeletions.deletedBoards.length, 'boards');
|
||||
await this.clearLocalDeletions(localDeletions.deletedBoards.map(b => b.id));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
await this.saveConfig({
|
||||
lastSyncTime: Date.now(),
|
||||
});
|
||||
// 更新配置
|
||||
await this.saveConfig({
|
||||
lastSyncTime: Date.now(),
|
||||
});
|
||||
|
||||
result.success = true;
|
||||
result.success = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.setStatus('synced');
|
||||
@@ -819,11 +870,19 @@ class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除远程有但本地没有的画板(通过设置内容为 null)
|
||||
// 软删除远程有但本地没有的画板(标记 deletedAt,保留文件以便恢复)
|
||||
for (const remoteBoardId of Object.keys(remoteManifest.boards)) {
|
||||
const remoteInfo = remoteManifest.boards[remoteBoardId];
|
||||
// 跳过已经标记为删除的画板
|
||||
if (remoteInfo.deletedAt) continue;
|
||||
|
||||
if (!localData.boards.has(remoteBoardId)) {
|
||||
filesToUpdate[SYNC_FILES.boardFile(remoteBoardId)] = ''; // 空字符串表示删除
|
||||
console.log(`[SyncEngine] pushToRemote: Board ${remoteBoardId} will be deleted from remote`);
|
||||
// 软删除:在 manifest 中标记 deletedAt,但保留画板文件
|
||||
localData.manifest.boards[remoteBoardId] = {
|
||||
...remoteInfo,
|
||||
deletedAt: Date.now(),
|
||||
};
|
||||
console.log(`[SyncEngine] pushToRemote: Board ${remoteBoardId} soft-deleted (moved to recycle bin)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -874,6 +933,11 @@ class SyncEngine {
|
||||
console.log('[SyncEngine] pushToRemote: Success', result.uploaded);
|
||||
this.setStatus('synced');
|
||||
this.pendingChanges = false;
|
||||
// 已以本地为准覆盖远程,本地删除已生效,清除待同步删除记录
|
||||
const pending = await this.getLocalDeletionsPendingSync();
|
||||
if (pending.size > 0) {
|
||||
await this.clearLocalDeletions(Array.from(pending.keys()));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SyncEngine] pushToRemote failed:', error);
|
||||
result.error = error instanceof Error ? error.message : '上传失败';
|
||||
@@ -955,16 +1019,59 @@ class SyncEngine {
|
||||
// 收集本地数据用于比较
|
||||
const localData = await dataSerializer.collectSyncData();
|
||||
|
||||
// 获取上次同步时间,用于判断本地删除
|
||||
const lastSyncTime = config.lastSyncTime;
|
||||
console.log('[SyncEngine] pullFromRemote: lastSyncTime:', lastSyncTime);
|
||||
|
||||
// 本地已删除、尚未同步的画板(ID -> 删除时间戳)
|
||||
const localDeletionsPending = await this.getLocalDeletionsPendingSync();
|
||||
console.log('[SyncEngine] pullFromRemote: Local deletions pending:',
|
||||
localDeletionsPending.size > 0
|
||||
? Object.fromEntries(localDeletionsPending)
|
||||
: 'none'
|
||||
);
|
||||
|
||||
// 确定需要下载的画板(增量)
|
||||
const boardsToDownload: string[] = [];
|
||||
const boardsToDelete: string[] = [];
|
||||
const boardsSkippedDueToLocalDeletion: string[] = [];
|
||||
|
||||
// 检查远程画板
|
||||
for (const [remoteBoardId, remoteInfo] of Object.entries(remoteManifest.boards)) {
|
||||
// 远程已标记删除(tombstone),不下载
|
||||
if (remoteInfo.deletedAt) continue;
|
||||
|
||||
// 检查本地是否已删除此画板(通过 recordLocalDeletion 记录)
|
||||
const localDeletedAt = localDeletionsPending.get(remoteBoardId);
|
||||
if (localDeletedAt !== undefined) {
|
||||
// 本地已删除:比较时间戳
|
||||
// 如果远程 updatedAt > 本地删除时间,说明远程有更新,应该恢复
|
||||
// 如果远程 updatedAt <= 本地删除时间,说明本地删除更新,保持删除
|
||||
if (remoteInfo.updatedAt > localDeletedAt) {
|
||||
console.log(`[SyncEngine] pullFromRemote: Board ${remoteBoardId} was locally deleted at ${localDeletedAt}, but remote is newer (${remoteInfo.updatedAt}), will restore`);
|
||||
boardsToDownload.push(remoteBoardId);
|
||||
} else {
|
||||
console.log(`[SyncEngine] pullFromRemote: Board ${remoteBoardId} was locally deleted at ${localDeletedAt}, remote is older (${remoteInfo.updatedAt}), keeping deleted`);
|
||||
boardsSkippedDueToLocalDeletion.push(remoteBoardId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const localBoard = localData.boards.get(remoteBoardId);
|
||||
if (!localBoard) {
|
||||
// 本地没有,需要下载
|
||||
boardsToDownload.push(remoteBoardId);
|
||||
// 本地没有此画板,检查是否是"本地删除"还是"远程新增"
|
||||
// 使用 lastSyncTime 判断:
|
||||
// - 如果 lastSyncTime 存在,且远程 updatedAt <= lastSyncTime,
|
||||
// 说明此画板在上次同步时已存在于远程,但现在本地没有 → 本地删除的,不恢复
|
||||
// - 如果 lastSyncTime 不存在(首次同步),或远程 updatedAt > lastSyncTime,
|
||||
// 说明这是远程新增/更新的画板 → 应该恢复
|
||||
if (lastSyncTime && remoteInfo.updatedAt <= lastSyncTime) {
|
||||
console.log(`[SyncEngine] pullFromRemote: Board ${remoteBoardId} existed at last sync (updatedAt: ${remoteInfo.updatedAt} <= lastSyncTime: ${lastSyncTime}), but not local now, treating as locally deleted`);
|
||||
boardsSkippedDueToLocalDeletion.push(remoteBoardId);
|
||||
} else {
|
||||
console.log(`[SyncEngine] pullFromRemote: Board ${remoteBoardId} is new/updated after last sync (updatedAt: ${remoteInfo.updatedAt}, lastSyncTime: ${lastSyncTime}), will download`);
|
||||
boardsToDownload.push(remoteBoardId);
|
||||
}
|
||||
} else {
|
||||
// 本地有,比较 checksum
|
||||
const localChecksum = dataSerializer.calculateBoardChecksum(localBoard);
|
||||
@@ -982,7 +1089,7 @@ class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SyncEngine] pullFromRemote: Need to download', boardsToDownload.length, 'boards, delete', boardsToDelete.length, 'boards');
|
||||
console.log('[SyncEngine] pullFromRemote: Need to download', boardsToDownload.length, 'boards, delete', boardsToDelete.length, 'boards, skipped due to local deletion:', boardsSkippedDueToLocalDeletion.length);
|
||||
|
||||
// 只下载需要的文件
|
||||
const remoteFiles: Record<string, string> = {};
|
||||
@@ -1024,9 +1131,33 @@ class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤 workspace:只保留 boardsToApply 中画板的元数据,避免本地已删除画板被恢复后出现孤立元数据
|
||||
// 同时排除因本地删除而跳过的画板
|
||||
const appliedBoardIds = new Set(boardsToApply.keys());
|
||||
const skippedBoardIds = new Set(boardsSkippedDueToLocalDeletion);
|
||||
let workspaceToApply = remoteData.workspace;
|
||||
if (workspaceToApply?.boardMetadata) {
|
||||
workspaceToApply = {
|
||||
...workspaceToApply,
|
||||
boardMetadata: workspaceToApply.boardMetadata.filter(m =>
|
||||
appliedBoardIds.has(m.id) && !skippedBoardIds.has(m.id)
|
||||
),
|
||||
// 如果远程的 currentBoardId 是被本地删除的画板,不切换到它
|
||||
currentBoardId: workspaceToApply.currentBoardId &&
|
||||
appliedBoardIds.has(workspaceToApply.currentBoardId) &&
|
||||
!skippedBoardIds.has(workspaceToApply.currentBoardId)
|
||||
? workspaceToApply.currentBoardId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[SyncEngine] pullFromRemote: Workspace filtered, appliedBoardIds:', appliedBoardIds.size,
|
||||
'skippedBoardIds:', skippedBoardIds.size,
|
||||
'currentBoardId:', workspaceToApply?.currentBoardId || 'none');
|
||||
|
||||
console.log('[SyncEngine] pullFromRemote: Applying', boardsToApply.size, 'boards to local');
|
||||
const applied = await dataSerializer.applySyncData({
|
||||
workspace: remoteData.workspace,
|
||||
workspace: workspaceToApply,
|
||||
boards: boardsToApply,
|
||||
prompts: remoteData.prompts,
|
||||
tasks: remoteData.tasks,
|
||||
@@ -1047,6 +1178,26 @@ class SyncEngine {
|
||||
console.log('[SyncEngine] pullFromRemote: Success', result.downloaded);
|
||||
this.setStatus('synced');
|
||||
this.pendingChanges = false;
|
||||
|
||||
// 清理不再需要的本地删除记录:
|
||||
// 1. 已经被下载/恢复的画板(远程更新更新)
|
||||
// 2. 远程已经不存在的画板(不需要再跟踪删除状态)
|
||||
const deletionsToClean: string[] = [];
|
||||
for (const [deletedBoardId] of localDeletionsPending) {
|
||||
const remoteBoard = remoteManifest.boards[deletedBoardId];
|
||||
if (!remoteBoard) {
|
||||
// 远程已经没有这个画板了,删除记录可以清除
|
||||
deletionsToClean.push(deletedBoardId);
|
||||
} else if (boardsToDownload.includes(deletedBoardId)) {
|
||||
// 已经被恢复了,删除记录可以清除
|
||||
deletionsToClean.push(deletedBoardId);
|
||||
}
|
||||
// boardsSkippedDueToLocalDeletion 中的画板保留删除记录,等待 push 到远程
|
||||
}
|
||||
if (deletionsToClean.length > 0) {
|
||||
console.log('[SyncEngine] pullFromRemote: Cleaning up deletion records:', deletionsToClean);
|
||||
await this.clearLocalDeletions(deletionsToClean);
|
||||
}
|
||||
|
||||
// 异步下载媒体文件(总是尝试,因为任务可能已存在但媒体未缓存)
|
||||
this.downloadSyncedMediaAsync();
|
||||
@@ -1088,6 +1239,7 @@ class SyncEngine {
|
||||
* 标记有本地变更
|
||||
*/
|
||||
markDirty(): void {
|
||||
console.log('[SyncEngine] markDirty called, current status:', this.status, 'syncInProgress:', this.syncInProgress);
|
||||
this.pendingChanges = true;
|
||||
if (this.status === 'synced') {
|
||||
this.setStatus('local_changes');
|
||||
@@ -1102,30 +1254,165 @@ class SyncEngine {
|
||||
return this.pendingChanges;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地已删除、尚未同步到远程的画板(ID -> 删除时间戳)
|
||||
* 下载远程时按时间戳判断是否恢复
|
||||
*/
|
||||
async getLocalDeletionsPendingSync(): Promise<Map<string, number>> {
|
||||
// 兼容旧格式(string[])和新格式(Record<string, number>)
|
||||
const data = await kvStorageService.get<string[] | Record<string, number>>(LOCAL_DELETIONS_PENDING_KEY);
|
||||
console.log('[SyncEngine] getLocalDeletionsPendingSync: raw data from storage:', data);
|
||||
if (!data) return new Map();
|
||||
|
||||
// 旧格式:string[] -> 转换为 Map,使用当前时间作为删除时间
|
||||
if (Array.isArray(data)) {
|
||||
console.log('[SyncEngine] getLocalDeletionsPendingSync: migrating from old format (string[])');
|
||||
const now = Date.now();
|
||||
const map = new Map<string, number>();
|
||||
data.forEach(id => map.set(id, now));
|
||||
// 迁移到新格式
|
||||
await kvStorageService.set(LOCAL_DELETIONS_PENDING_KEY, Object.fromEntries(map));
|
||||
return map;
|
||||
}
|
||||
|
||||
// 新格式:Record<string, number>
|
||||
const map = new Map(Object.entries(data));
|
||||
console.log('[SyncEngine] getLocalDeletionsPendingSync: loaded', map.size, 'deletions');
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录本地删除的画板(带时间戳)
|
||||
* 用于下载远程时按时间戳判断是否恢复
|
||||
*/
|
||||
async recordLocalDeletion(boardId: string): Promise<void> {
|
||||
const map = await this.getLocalDeletionsPendingSync();
|
||||
const deletedAt = Date.now();
|
||||
map.set(boardId, deletedAt);
|
||||
console.log('[SyncEngine] recordLocalDeletion:', boardId, 'at', deletedAt);
|
||||
await kvStorageService.set(LOCAL_DELETIONS_PENDING_KEY, Object.fromEntries(map));
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即将画板删除同步到远程回收站
|
||||
* 在远程 manifest 中标记 deletedAt,保留画板文件以便恢复
|
||||
*/
|
||||
async syncBoardDeletion(boardId: string): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[SyncEngine] syncBoardDeletion:', boardId);
|
||||
|
||||
// 检查是否已配置
|
||||
if (!tokenService.hasToken()) {
|
||||
console.log('[SyncEngine] syncBoardDeletion: No token, skipping');
|
||||
return { success: false, error: '未配置 GitHub Token' };
|
||||
}
|
||||
|
||||
const config = await this.getConfig();
|
||||
if (!config.gistId) {
|
||||
console.log('[SyncEngine] syncBoardDeletion: No gistId, skipping');
|
||||
return { success: false, error: '未配置同步 Gist' };
|
||||
}
|
||||
|
||||
try {
|
||||
gitHubApiService.setGistId(config.gistId);
|
||||
|
||||
// 获取远程 manifest
|
||||
const manifestContent = await gitHubApiService.getGistFileContent(SYNC_FILES.MANIFEST);
|
||||
if (!manifestContent) {
|
||||
return { success: false, error: '远程 manifest 不存在' };
|
||||
}
|
||||
|
||||
const manifest: SyncManifest = JSON.parse(manifestContent);
|
||||
const boardInfo = manifest.boards[boardId];
|
||||
|
||||
if (!boardInfo) {
|
||||
console.log('[SyncEngine] syncBoardDeletion: Board not found in remote manifest');
|
||||
// 画板不在远程,可能是新建后未同步就删除了,直接返回成功
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (boardInfo.deletedAt) {
|
||||
console.log('[SyncEngine] syncBoardDeletion: Board already deleted in remote');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
const userInfo = await tokenService.getUserInfo();
|
||||
const deletedBy = userInfo?.login || 'unknown';
|
||||
|
||||
// 标记为已删除
|
||||
const deletedAt = Date.now();
|
||||
manifest.boards[boardId] = {
|
||||
...boardInfo,
|
||||
deletedAt,
|
||||
deletedBy,
|
||||
};
|
||||
manifest.updatedAt = deletedAt;
|
||||
|
||||
// 更新远程 manifest(保持原有的加密标志)
|
||||
// manifest 已经包含 encrypted 和 customPassword 字段
|
||||
await gitHubApiService.updateGistFiles({
|
||||
[SYNC_FILES.MANIFEST]: JSON.stringify(manifest, null, 2),
|
||||
});
|
||||
|
||||
console.log('[SyncEngine] syncBoardDeletion: Manifest updated with encryption flags preserved');
|
||||
|
||||
console.log('[SyncEngine] syncBoardDeletion: Success, board moved to recycle bin');
|
||||
|
||||
// 清除本地删除记录(已同步到远程)
|
||||
await this.clearLocalDeletions([boardId]);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[SyncEngine] syncBoardDeletion failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '同步删除失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除已同步到远程的本地删除记录
|
||||
*/
|
||||
async clearLocalDeletions(boardIds: string[]): Promise<void> {
|
||||
if (boardIds.length === 0) return;
|
||||
const map = await this.getLocalDeletionsPendingSync();
|
||||
boardIds.forEach(id => map.delete(id));
|
||||
console.log('[SyncEngine] clearLocalDeletions:', boardIds);
|
||||
await kvStorageService.set(LOCAL_DELETIONS_PENDING_KEY, Object.fromEntries(map));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度自动同步
|
||||
*/
|
||||
private async scheduleAutoSync(): Promise<void> {
|
||||
// 检查是否有 token(已配置)
|
||||
if (!tokenService.hasToken()) {
|
||||
console.log('[SyncEngine] scheduleAutoSync: No token, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await this.getConfig();
|
||||
console.log('[SyncEngine] scheduleAutoSync: config.autoSync =', config.autoSync, 'debounceMs =', config.autoSyncDebounceMs);
|
||||
if (!config.autoSync) {
|
||||
console.log('[SyncEngine] scheduleAutoSync: Auto sync disabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的计时器
|
||||
if (this.autoSyncTimer) {
|
||||
console.log('[SyncEngine] scheduleAutoSync: Clearing previous timer');
|
||||
clearTimeout(this.autoSyncTimer);
|
||||
}
|
||||
|
||||
// 设置新的计时器
|
||||
this.autoSyncTimer = setTimeout(async () => {
|
||||
console.log('[SyncEngine] Auto sync timer fired, pendingChanges:', this.pendingChanges, 'syncInProgress:', this.syncInProgress);
|
||||
if (this.pendingChanges && !this.syncInProgress) {
|
||||
console.log('[SyncEngine] Auto sync triggered after debounce');
|
||||
await this.sync();
|
||||
} else {
|
||||
console.log('[SyncEngine] Auto sync skipped: pendingChanges =', this.pendingChanges, 'syncInProgress =', this.syncInProgress);
|
||||
}
|
||||
}, config.autoSyncDebounceMs);
|
||||
|
||||
@@ -1294,10 +1581,15 @@ class SyncEngine {
|
||||
|
||||
// 解密(如果需要)
|
||||
const { cryptoService } = await import('./crypto-service');
|
||||
const manifestExt = manifest as SyncManifest & { encrypted?: boolean };
|
||||
const manifestExt = manifest as SyncManifest & { encrypted?: boolean; customPassword?: boolean };
|
||||
let boardJson = boardContent;
|
||||
if (manifestExt.encrypted) {
|
||||
boardJson = await cryptoService.decryptOrPassthrough(boardContent, config.gistId);
|
||||
// 如果使用了自定义密码,需要获取密码
|
||||
let customPassword: string | undefined;
|
||||
if (manifestExt.customPassword) {
|
||||
customPassword = await syncPasswordService.getPassword() || undefined;
|
||||
}
|
||||
boardJson = await cryptoService.decryptOrPassthrough(boardContent, config.gistId, customPassword);
|
||||
}
|
||||
|
||||
const board: BoardData = JSON.parse(boardJson);
|
||||
|
||||
@@ -243,27 +243,18 @@ class SWTaskQueueService {
|
||||
* 恢复任务到本地状态、持久化到 SW 并通知 UI 更新
|
||||
*/
|
||||
async restoreTasks(tasks: Task[]): Promise<void> {
|
||||
console.log('[SWTaskQueue] restoreTasks called with', tasks.length, 'tasks');
|
||||
console.log('[SWTaskQueue] Current local tasks:', this.tasks.size, 'ids:', Array.from(this.tasks.keys()).slice(0, 5));
|
||||
|
||||
// 过滤出本地不存在的任务
|
||||
const tasksToRestore = tasks.filter(task => !this.tasks.has(task.id));
|
||||
|
||||
console.log('[SWTaskQueue] Tasks to restore:', tasksToRestore.length);
|
||||
|
||||
|
||||
if (tasksToRestore.length === 0) {
|
||||
console.log('[SWTaskQueue] No new tasks to restore (all tasks already exist locally)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 转换为 SWTask 格式并导入到 SW
|
||||
const swTasks: SWTask[] = tasksToRestore.map(task => this.convertTaskToSWTask(task));
|
||||
|
||||
console.log('[SWTaskQueue] Restoring', swTasks.length, 'tasks to SW...');
|
||||
|
||||
|
||||
// 调用 SW 的 importTasks 方法持久化任务
|
||||
const result = await swChannelClient.importTasks(swTasks);
|
||||
console.log('[SWTaskQueue] Import result:', result);
|
||||
|
||||
if (result.success) {
|
||||
// 添加到本地内存
|
||||
|
||||
@@ -496,6 +496,24 @@ class WorkspaceService {
|
||||
const board = this.boards.get(id);
|
||||
if (!board) throw new Error(`Board ${id} not found`);
|
||||
|
||||
console.log('[WorkspaceService] deleteBoard called:', id, board.name);
|
||||
|
||||
// 异步同步删除到远程回收站(不阻塞本地删除)
|
||||
import('./github-sync').then(({ syncEngine }) => {
|
||||
console.log('[WorkspaceService] Syncing board deletion to remote (async)...');
|
||||
syncEngine.syncBoardDeletion(id).then((result) => {
|
||||
if (result.success) {
|
||||
console.log('[WorkspaceService] Board synced to remote recycle bin:', id);
|
||||
} else {
|
||||
console.warn('[WorkspaceService] Failed to sync deletion to remote:', result.error);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[WorkspaceService] Could not sync deletion to remote:', err);
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.warn('[WorkspaceService] Could not load syncEngine:', err);
|
||||
});
|
||||
|
||||
// Clear current if this board is active
|
||||
if (this.state.currentBoardId === id) {
|
||||
this.state.currentBoardId = null;
|
||||
@@ -506,6 +524,7 @@ class WorkspaceService {
|
||||
this.boardMetadata.delete(id);
|
||||
this.loadedBoards.delete(id);
|
||||
await workspaceStorageService.deleteBoard(id);
|
||||
console.log('[WorkspaceService] Emitting boardDeleted event for:', id);
|
||||
this.emit('boardDeleted', board);
|
||||
}
|
||||
|
||||
@@ -1062,7 +1081,9 @@ class WorkspaceService {
|
||||
}
|
||||
|
||||
private emit(type: WorkspaceEvent['type'], payload?: unknown): void {
|
||||
this.events$.next({ type, payload, timestamp: Date.now() });
|
||||
const event = { type, payload, timestamp: Date.now() };
|
||||
console.log('[WorkspaceService] emit() called:', type, 'observers:', this.events$.observers.length);
|
||||
this.events$.next(event);
|
||||
}
|
||||
|
||||
// ========== Initialization ==========
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
--td-brand-color-active: #D35400;
|
||||
--td-brand-color-disabled: #FDE68A;
|
||||
|
||||
/* 辅助色 - 蓝紫色系 (用于次要操作) */
|
||||
--td-brand-color-light: #7B68EE;
|
||||
--td-brand-color-light-hover: #5A4FCF;
|
||||
--td-brand-color-light-active: #4338CA;
|
||||
/* 品牌色浅色版本 - 用于选中/高亮背景 */
|
||||
--td-brand-color-light: rgba(243, 156, 18, 0.12);
|
||||
--td-brand-color-light-hover: rgba(243, 156, 18, 0.18);
|
||||
--td-brand-color-light-active: rgba(243, 156, 18, 0.24);
|
||||
|
||||
/* 成功色 - 保持TDesign默认或使用品牌绿色 */
|
||||
--td-success-color: #00B894;
|
||||
|
||||
Reference in new issue
Block a user