docs: 添加 CSS 变量优先级和弹出菜单截断规则

- CLAUDE.md: 更新 createPortal 规则说明,新增组件内颜色使用规范
- CODING_RULES.md: 添加详细的 TDesign CSS 变量优先级问题说明
- CODING_RULES.md: 添加弹出菜单被 overflow 截断的解决方案
This commit is contained in:
liquidliang committed 2026-02-01 22:19:02 +08:00
1 parent a36176ba97
commit 8ecc8e7571
25 files changed
+1709 -333

No files matched your search

+2 -1
View File
@@ -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)`)
---
+14 -8
View File
@@ -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();
+113 -14
View File
@@ -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>
+92 -25
View File
@@ -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;
}
+5 -3
View File
@@ -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 -1
View File
@@ -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": [
"添加\"不再提示\"按钮到灵感创意板块"
+7 -4
View File
@@ -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
View File
@@ -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);
+22 -4
View File
@@ -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',
+81 -13
View File
@@ -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 有效,故不监听
}
+188
View File
@@ -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;