From e8b1f31b82af4f2fc1208e1dae6309610f94e35b Mon Sep 17 00:00:00 2001 From: liquidliang Date: Fri, 16 Jan 2026 14:30:13 +0800 Subject: [PATCH 1/3] feat(viewport): implement viewport saving and restoration functionality - Added viewport saving with debounce to prevent excessive saves during panning and zooming. - Implemented viewport restoration on component mount and visibility change. - Enhanced WorkspaceService to support waiting for initialization before proceeding with operations. - Updated Wrapper component to handle viewport changes and apply them accordingly. This improves user experience by ensuring viewport state is preserved across sessions and interactions. --- apps/web/src/app/app.tsx | 99 ++++++++++++++++++- .../drawnix/src/services/workspace-service.ts | 24 +++++ packages/react-board/src/wrapper.tsx | 34 ++++++- 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx index 97019f69..d2125b36 100644 --- a/apps/web/src/app/app.tsx +++ b/apps/web/src/app/app.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Drawnix, WorkspaceService, @@ -9,6 +9,9 @@ import { } from '@drawnix/drawnix'; import { PlaitBoard, PlaitElement, PlaitTheme, Viewport } from '@plait/core'; +// 节流保存 viewport 的间隔(毫秒) +const VIEWPORT_SAVE_DEBOUNCE = 500; + // Global flag to prevent duplicate initialization in StrictMode let appInitialized = false; @@ -21,12 +24,19 @@ export function App() { theme?: PlaitTheme; }>({ children: [] }); + // 存储最新的 viewport,用于页面关闭前保存 + const latestViewportRef = useRef(); + // 防抖定时器 + const viewportSaveTimerRef = useRef(null); + // Initialize workspace and handle migration useEffect(() => { const initialize = async () => { // Prevent duplicate initialization in StrictMode if (appInitialized) { + // 等待 workspaceService 完全初始化 const workspaceService = WorkspaceService.getInstance(); + await workspaceService.waitForInitialization(); const currentBoard = workspaceService.getCurrentBoard(); if (currentBoard) { setValue({ @@ -68,7 +78,7 @@ export function App() { if (currentBoard) { const elements = currentBoard.elements || []; - + // 先设置原始元素,让页面先渲染 setValue({ children: elements, @@ -118,6 +128,8 @@ export function App() { const handleBoardChange = useCallback( (data: BoardChangeData) => { setValue(data); + // 同步更新最新 viewport + latestViewportRef.current = data.viewport; // Save to current board const workspaceService = WorkspaceService.getInstance(); @@ -128,6 +140,88 @@ export function App() { [] ); + // Handle viewport changes (pan/zoom) - 单独保存 viewport + const handleViewportChange = useCallback( + (viewport: Viewport) => { + // 更新最新 viewport + latestViewportRef.current = viewport; + + // 防抖保存 + if (viewportSaveTimerRef.current) { + clearTimeout(viewportSaveTimerRef.current); + } + viewportSaveTimerRef.current = setTimeout(() => { + const workspaceService = WorkspaceService.getInstance(); + const currentBoard = workspaceService.getCurrentBoard(); + if (currentBoard) { + // 只保存 viewport,不影响其他数据 + workspaceService.saveCurrentBoard({ + children: currentBoard.elements, + viewport: viewport, + theme: currentBoard.theme, + }).catch((err: Error) => { + console.error('[App] Failed to save viewport:', err); + }); + } + }, VIEWPORT_SAVE_DEBOUNCE); + }, + [] + ); + + // 页面关闭/隐藏前保存 viewport + useEffect(() => { + // 立即保存 viewport 的函数 + const saveViewportImmediately = () => { + // 清除防抖定时器 + if (viewportSaveTimerRef.current) { + clearTimeout(viewportSaveTimerRef.current); + viewportSaveTimerRef.current = null; + } + + // 同步保存最新的 viewport + const viewport = latestViewportRef.current; + if (viewport) { + const workspaceService = WorkspaceService.getInstance(); + const currentBoard = workspaceService.getCurrentBoard(); + if (currentBoard) { + // 直接更新内存中的 board 数据 + currentBoard.viewport = viewport; + // 尝试保存 + workspaceService.saveCurrentBoard({ + children: currentBoard.elements, + viewport: viewport, + theme: currentBoard.theme, + }).catch(() => { + // 忽略错误 + }); + } + } + }; + + const handleBeforeUnload = () => { + saveViewportImmediately(); + }; + + // 页面隐藏时也保存(处理移动端和标签页切换) + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + saveViewportImmediately(); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('visibilitychange', handleVisibilityChange); + // 清理定时器 + if (viewportSaveTimerRef.current) { + clearTimeout(viewportSaveTimerRef.current); + } + }; + }, []); + if (isLoading) { return (
{ diff --git a/packages/drawnix/src/services/workspace-service.ts b/packages/drawnix/src/services/workspace-service.ts index e42370fa..c4569c0e 100644 --- a/packages/drawnix/src/services/workspace-service.ts +++ b/packages/drawnix/src/services/workspace-service.ts @@ -42,6 +42,7 @@ class WorkspaceService { private state: WorkspaceState; private events$: Subject = new Subject(); private initialized: boolean = false; + private initializationPromise: Promise | null = null; private constructor() { this.state = { @@ -64,7 +65,17 @@ class WorkspaceService { */ async initialize(): Promise { if (this.initialized) return; + + // 如果正在初始化,等待完成 + if (this.initializationPromise) { + return this.initializationPromise; + } + this.initializationPromise = this.doInitialize(); + return this.initializationPromise; + } + + private async doInitialize(): Promise { try { await workspaceStorageService.initialize(); @@ -95,10 +106,23 @@ class WorkspaceService { // }); } catch (error) { console.error('[WorkspaceService] Failed to initialize:', error); + this.initializationPromise = null; throw error; } } + /** + * Wait for initialization to complete + */ + async waitForInitialization(): Promise { + if (this.initialized) return; + if (this.initializationPromise) { + return this.initializationPromise; + } + // 如果还没开始初始化,启动初始化 + return this.initialize(); + } + // ========== Folder Operations ========== async createFolder(options: CreateFolderOptions): Promise { diff --git a/packages/react-board/src/wrapper.tsx b/packages/react-board/src/wrapper.tsx index 72c07b01..f72249cf 100644 --- a/packages/react-board/src/wrapper.tsx +++ b/packages/react-board/src/wrapper.tsx @@ -128,7 +128,7 @@ export const Wrapper: React.FC = ({ board, listRender, })); - }, [board, onChange, onSelectionChange, onValueChange]); + }, [board, onChange, onSelectionChange, onValueChange, onViewportChange, onThemeChange]); useEffect(() => { BOARD_TO_ON_CHANGE.set(board, () => { @@ -185,6 +185,30 @@ export const Wrapper: React.FC = ({ }, [board]); const isFirstRender = useRef(true); + const prevViewportRef = useRef(viewport); + + // 处理 viewport prop 变化(用于恢复保存的视图状态) + useEffect(() => { + const prevViewport = prevViewportRef.current; + prevViewportRef.current = viewport; + + // 如果外部传入了有效的 viewport,且与当前不同,则应用它 + if (viewport && !FLUSHING.get(board)) { + // 检查是否是从 undefined 变为有值,或者值发生了变化 + const isNewViewport = !prevViewport; + const isDifferent = prevViewport && ( + prevViewport.zoom !== viewport.zoom || + prevViewport.offsetX !== viewport.offsetX || + prevViewport.offsetY !== viewport.offsetY + ); + + if (isNewViewport || isDifferent) { + board.viewport = viewport; + initializeViewBox(board); + updateViewportOffset(board); + } + } + }, [viewport, board]); useEffect(() => { if (isFirstRender.current) { @@ -199,9 +223,13 @@ export const Wrapper: React.FC = ({ parent: board, parentG: PlaitBoard.getElementHost(board), }); - BoardTransforms.fitViewport(board); + // 只有当没有传入 viewport 时才自动适配视图 + // 如果传入了 viewport,说明是恢复保存的视图,不应该重置 + if (!viewport) { + BoardTransforms.fitViewport(board); + } } - }, [value]); + }, [value, viewport]); return ( {children} From 00df2f7e8410a30b51565ae5c151c8e3370071a7 Mon Sep 17 00:00:00 2001 From: liquidliang Date: Fri, 16 Jan 2026 15:38:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(claude):=20=E6=B7=BB=E5=8A=A0=E6=96=B0?= =?UTF-8?q?=E8=A7=84=E5=88=99=20-=20=E7=94=9F=E4=BA=A7=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=A6=81=E6=AD=A2=E4=BF=9D=E7=95=99=E8=B0=83=E8=AF=95=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 46 +++++++++ .../backup-restore/backup-restore-dialog.tsx | 86 ++++++++++++++-- .../project-drawer/ProjectDrawer.tsx | 33 ++++++- .../view-navigation/ViewNavigation.tsx | 11 +-- packages/drawnix/src/drawnix.tsx | 22 +++++ .../src/services/backup-restore-service.ts | 99 ++++++++++++++++--- 6 files changed, 271 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 18662c06..aaad5727 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2320,6 +2320,52 @@ trimTransparentBorders(imageData: ImageData, strict: boolean = true) --- +## 开发规范 + +### 生产代码禁止保留调试日志 + +**场景**: 开发调试时添加的 `console.log` 语句未在提交前清理 + +❌ **错误示例**: +```typescript +// 调试日志遗留在生产代码中 +const handleZoomPercentClick = useCallback(() => { + console.log('[ViewNavigation] Zoom percent clicked, current state:', zoomMenuOpen); + setZoomMenuOpen((prev) => !prev); +}, [zoomMenuOpen]); + +// Popover 中的调试日志 + { + console.log('[Popover] onOpenChange:', open); + setZoomMenuOpen(open); + }} +> +``` + +✅ **正确示例**: +```typescript +// 清理调试日志,保持代码简洁 +const handleZoomPercentClick = useCallback(() => { + setZoomMenuOpen((prev) => !prev); +}, []); + +// 直接传递 setter 函数 + +``` + +**原因**: +1. 调试日志会污染用户控制台,影响体验 +2. 暴露内部实现细节,存在安全隐患 +3. 增加打包体积和运行时开销 +4. 代码 Review 时容易被忽略,形成技术债 + +**例外情况**: +- `console.error` / `console.warn` 用于记录真正的错误/警告是允许的 +- 带有 `[DEBUG]` 前缀且通过环境变量控制的日志可以保留 + +--- + ## 相关文档 - `/docs/CODING_STANDARDS.md` - 完整编码规范 diff --git a/packages/drawnix/src/components/backup-restore/backup-restore-dialog.tsx b/packages/drawnix/src/components/backup-restore/backup-restore-dialog.tsx index c943fba5..a0551f30 100644 --- a/packages/drawnix/src/components/backup-restore/backup-restore-dialog.tsx +++ b/packages/drawnix/src/components/backup-restore/backup-restore-dialog.tsx @@ -7,19 +7,25 @@ import { Dialog, DialogContent } from '../dialog/dialog'; import { useState, useRef, useCallback } from 'react'; -import { Checkbox, MessagePlugin, Progress } from 'tdesign-react'; +import { Checkbox, MessagePlugin, Progress, DialogPlugin } from 'tdesign-react'; import { UploadIcon } from 'tdesign-icons-react'; import { backupRestoreService, BackupOptions, ImportResult, + BackupWorkspaceState, } from '../../services/backup-restore-service'; +import { workspaceService } from '../../services/workspace-service'; import './backup-restore-dialog.scss'; export interface BackupRestoreDialogProps { open: boolean; onOpenChange: (open: boolean) => void; container: HTMLElement | null; + /** 切换画板的回调(用于恢复备份时的画布状态) */ + onSwitchBoard?: (boardId: string, viewport?: BackupWorkspaceState['viewport']) => void; + /** 导入前的回调(用于保存当前画板数据到 IndexedDB) */ + onBeforeImport?: () => Promise; } type TabType = 'backup' | 'restore'; @@ -28,6 +34,8 @@ export const BackupRestoreDialog = ({ open, onOpenChange, container, + onSwitchBoard, + onBeforeImport, }: BackupRestoreDialogProps) => { const [activeTab, setActiveTab] = useState('backup'); const [backupOptions, setBackupOptions] = useState({ @@ -39,10 +47,64 @@ export const BackupRestoreDialog = ({ const [progress, setProgress] = useState(0); const [progressMessage, setProgressMessage] = useState(''); const [importResult, setImportResult] = useState(null); + // 待恢复的工作区状态(用于延迟处理画布切换) + const [pendingWorkspaceState, setPendingWorkspaceState] = useState(null); const fileInputRef = useRef(null); - const handleClose = useCallback(() => { + /** + * 处理画布切换逻辑 + * - 如果当前是空白画布,自动切换到备份时的画布 + * - 如果当前不是空白画布,弹窗询问用户 + */ + const handleWorkspaceRestore = useCallback(async (workspaceState: BackupWorkspaceState) => { + if (!workspaceState.currentBoardId || !onSwitchBoard) return; + + // 检查备份的画板是否存在 + const targetBoard = workspaceService.getBoard(workspaceState.currentBoardId); + if (!targetBoard) { + // 画板不存在,可能是增量导入时没有导入该画板 + return; + } + + // 获取当前画板 + const currentBoard = workspaceService.getCurrentBoard(); + + // 检查当前画布是否为空白(没有元素或只有默认元素) + const isCurrentBoardEmpty = !currentBoard || + !currentBoard.elements || + currentBoard.elements.length === 0; + + if (isCurrentBoardEmpty) { + // 当前是空白画布,自动切换 + await onSwitchBoard(workspaceState.currentBoardId, workspaceState.viewport); + MessagePlugin.success(`已切换到画板「${targetBoard.name}」`); + } else { + // 当前不是空白画布,弹窗询问 + const dialogInstance = DialogPlugin.confirm({ + header: '恢复画布状态', + body: `备份时正在编辑画板「${workspaceState.currentBoardName || targetBoard.name}」,是否切换到该画板?`, + confirmBtn: '切换', + cancelBtn: '取消', + onConfirm: async () => { + await onSwitchBoard(workspaceState.currentBoardId!, workspaceState.viewport); + MessagePlugin.success(`已切换到画板「${targetBoard.name}」`); + dialogInstance.destroy(); + }, + onCancel: () => { + dialogInstance.destroy(); + }, + }); + } + }, [onSwitchBoard]); + + const handleClose = useCallback(async () => { if (!isProcessing) { + // 如果有待恢复的工作区状态,处理画布切换(需要等待完成) + if (pendingWorkspaceState) { + await handleWorkspaceRestore(pendingWorkspaceState); + setPendingWorkspaceState(null); + } + // 如果有导入结果且导入了任务数据,刷新页面以确保任务队列生效 if (importResult && importResult.tasks && importResult.tasks.imported > 0) { window.location.reload(); @@ -55,7 +117,7 @@ export const BackupRestoreDialog = ({ setProgressMessage(''); setImportResult(null); } - }, [isProcessing, onOpenChange, importResult]); + }, [isProcessing, onOpenChange, importResult, pendingWorkspaceState, handleWorkspaceRestore]); const handleBackup = useCallback(async () => { // 检查是否至少选择了一项 @@ -104,10 +166,16 @@ export const BackupRestoreDialog = ({ setIsProcessing(true); setProgress(0); - setProgressMessage('正在读取文件...'); + setProgressMessage('正在保存当前画板...'); setImportResult(null); try { + // 导入前先保存当前画板数据,确保合并时使用最新数据 + if (onBeforeImport) { + await onBeforeImport(); + } + + setProgressMessage('正在读取文件...'); const result = await backupRestoreService.importFromZip( file, (p, msg) => { @@ -118,6 +186,11 @@ export const BackupRestoreDialog = ({ setImportResult(result); + // 保存工作区状态,关闭对话框时处理 + if (result.workspaceState?.currentBoardId) { + setPendingWorkspaceState(result.workspaceState); + } + if (result.success) { MessagePlugin.success('导入成功!'); } else if (result.errors.length > 0) { @@ -133,7 +206,7 @@ export const BackupRestoreDialog = ({ fileInputRef.current.value = ''; } } - }, []); + }, [onBeforeImport]); const handleOptionChange = useCallback((key: keyof BackupOptions, checked: boolean) => { setBackupOptions((prev) => ({ ...prev, [key]: checked })); @@ -272,9 +345,10 @@ export const BackupRestoreDialog = ({ 提示词:导入 {importResult.prompts.imported} 条,跳过 {importResult.prompts.skipped} 条 )} - {(importResult.projects.folders > 0 || importResult.projects.boards > 0) && ( + {(importResult.projects.folders > 0 || importResult.projects.boards > 0 || importResult.projects.merged > 0) && (
  • 项目:导入 {importResult.projects.folders} 个文件夹,{importResult.projects.boards} 个画板 + {importResult.projects.merged > 0 && `,合并 ${importResult.projects.merged} 个画板`}
  • )} {(importResult.assets.imported > 0 || importResult.assets.skipped > 0) && ( diff --git a/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx b/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx index c270211a..1bf0ca1f 100644 --- a/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx +++ b/packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx @@ -583,17 +583,48 @@ export const ProjectDrawer: React.FC = ({ setDeleteTarget(null); }, [deleteTarget, deleteFolderWithContents]); + // Helper function to get the first available board from tree (excluding a specific id) + 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; + } + if (node.type === 'folder' && node.children) { + const board = getFirstBoardFromTree(node.children, excludeId); + if (board) return board; + } + } + return null; + }, []); + // Handle delete (board) const handleDeleteBoard = useCallback(async () => { if (!deleteTarget || deleteTarget.type !== 'board') return; + const deletingCurrentBoard = deleteTarget.id === currentBoard?.id; const success = await deleteBoard(deleteTarget.id); + if (success) { MessagePlugin.success('删除成功'); + + // If we deleted the current board, switch to the first available board + if (deletingCurrentBoard) { + const firstBoard = getFirstBoardFromTree(tree, deleteTarget.id); + if (firstBoard) { + // Save before switching (though current board is deleted, this ensures clean state) + if (onBeforeSwitch) { + await onBeforeSwitch(); + } + const switched = await switchBoard(firstBoard.id); + if (switched && onBoardSwitch) { + onBoardSwitch(switched); + } + } + } } setShowDeleteDialog(false); setDeleteTarget(null); - }, [deleteTarget, deleteBoard]); + }, [deleteTarget, deleteBoard, currentBoard, tree, getFirstBoardFromTree, onBeforeSwitch, switchBoard, onBoardSwitch]); // Handle copy board const handleCopyBoard = useCallback(async (id: string) => { diff --git a/packages/drawnix/src/components/view-navigation/ViewNavigation.tsx b/packages/drawnix/src/components/view-navigation/ViewNavigation.tsx index e1596d03..1d348d1e 100644 --- a/packages/drawnix/src/components/view-navigation/ViewNavigation.tsx +++ b/packages/drawnix/src/components/view-navigation/ViewNavigation.tsx @@ -36,7 +36,8 @@ export const ViewNavigation: React.FC = ({ const { t } = useI18n(); const { isDrawerOpen, drawerWidth } = useChatDrawerControl(); const [zoomMenuOpen, setZoomMenuOpen] = useState(false); - const [minimapExpanded, setMinimapExpanded] = useState(false); + // 首次进入页面时默认展示 minimap + const [minimapExpanded, setMinimapExpanded] = useState(true); const [manuallyExpanded, setManuallyExpanded] = useState(false); const containerRef = useRef(null); @@ -176,9 +177,8 @@ export const ViewNavigation: React.FC = ({ // 处理百分比按钮点击 const handleZoomPercentClick = useCallback(() => { - console.log('[ViewNavigation] Zoom percent clicked, current state:', zoomMenuOpen); setZoomMenuOpen((prev) => !prev); - }, [zoomMenuOpen]); + }, []); // 当前缩放百分比 const zoomPercentage = Math.round((board?.viewport?.zoom || 1) * 100); @@ -207,10 +207,7 @@ export const ViewNavigation: React.FC = ({ { - console.log('[ViewNavigation] Popover onOpenChange:', open); - setZoomMenuOpen(open); - }} + onOpenChange={setZoomMenuOpen} placement="bottom" > diff --git a/packages/drawnix/src/drawnix.tsx b/packages/drawnix/src/drawnix.tsx index a67a5c57..73a4675c 100644 --- a/packages/drawnix/src/drawnix.tsx +++ b/packages/drawnix/src/drawnix.tsx @@ -55,6 +55,7 @@ import { ChatDrawerProvider, useChatDrawer } from './contexts/ChatDrawerContext' import { fontManagerService } from './services/font-manager-service'; import { WorkflowProvider } from './contexts/WorkflowContext'; import { useWorkspace } from './hooks/useWorkspace'; +import { workspaceService } from './services/workspace-service'; import { Board as WorkspaceBoard } from './types/workspace.types'; import { toolTestHelper } from './utils/tool-test-helper'; import { ViewNavigation } from './components/view-navigation'; @@ -865,6 +866,27 @@ const DrawnixContent: React.FC = ({ open={backupRestoreOpen} onOpenChange={setBackupRestoreOpen} container={containerRef.current} + onBeforeImport={async () => { + // 导入前先保存当前画板数据到 IndexedDB + if (handleBeforeSwitch) { + await handleBeforeSwitch(); + } + }} + onSwitchBoard={async (boardId, viewport) => { + // 注意:这里不调用 handleBeforeSwitch + // 因为在备份恢复时,onBeforeImport 已经保存了当前画板 + // 如果在这里再保存,会用旧的内存数据覆盖 IndexedDB 中刚合并的新数据 + + // 切换到目标画板(使用已导入的 workspaceService 单例,确保数据一致性) + const board = await workspaceService.switchBoard(boardId); + if (board && onBoardSwitch) { + // 如果有 viewport,合并到 board 中 + if (viewport) { + board.viewport = viewport; + } + onBoardSwitch(board); + } + }} /> )} diff --git a/packages/drawnix/src/services/backup-restore-service.ts b/packages/drawnix/src/services/backup-restore-service.ts index 0de3fff4..9f542b5c 100644 --- a/packages/drawnix/src/services/backup-restore-service.ts +++ b/packages/drawnix/src/services/backup-restore-service.ts @@ -36,6 +36,7 @@ import { swTaskQueueService } from './sw-task-queue-service'; import { TaskType, TaskStatus, Task } from '../types/task.types'; import type { Folder, Board } from '../types/workspace.types'; import type { StoredAsset } from '../types/asset.types'; +import type { PlaitTheme, PlaitElement as CorePlaitElement } from '@plait/core'; import { LS_KEYS_TO_MIGRATE } from '../constants/storage-keys'; import { DrawnixExportedType } from '../data/types'; import { VERSIONS } from '../constants'; @@ -71,6 +72,21 @@ export interface BackupOptions { includeAssets: boolean; } +/** + * 备份时的工作区状态 + */ +export interface BackupWorkspaceState { + /** 当前画板ID */ + currentBoardId: string | null; + /** 当前画板名称(用于显示) */ + currentBoardName?: string; + /** 当前视图状态(使用 origination 格式,与 @plait/core 保持一致) */ + viewport?: { + zoom: number; + origination?: [number, number]; + }; +} + /** * 备份清单(manifest.json) */ @@ -92,6 +108,8 @@ interface BackupManifest { assetCount: number; taskCount: number; }; + /** 备份时的工作区状态 */ + workspaceState?: BackupWorkspaceState; } /** @@ -126,8 +144,7 @@ interface DrawnixFileData { interface Viewport { zoom: number; - offsetX?: number; - offsetY?: number; + origination?: [number, number]; } interface PlaitElement { @@ -152,6 +169,8 @@ export interface ImportResult { projects: { folders: number; boards: number; + /** 合并的画板数量(相同ID的画板) */ + merged: number; skipped: number; }; assets: { @@ -163,6 +182,8 @@ export interface ImportResult { skipped: number; }; errors: string[]; + /** 备份时的工作区状态(用于恢复画布位置) */ + workspaceState?: BackupWorkspaceState; } /** @@ -215,6 +236,14 @@ class BackupRestoreService { onProgress?.(5, '正在准备数据...'); + // 获取当前工作区状态 + const currentBoard = workspaceService.getCurrentBoard(); + const workspaceState: BackupWorkspaceState = { + currentBoardId: currentBoard?.id || null, + currentBoardName: currentBoard?.name, + viewport: currentBoard?.viewport, + }; + const manifest: BackupManifest = { signature: BACKUP_SIGNATURE, version: BACKUP_VERSION, @@ -233,6 +262,7 @@ class BackupRestoreService { assetCount: 0, taskCount: 0, }, + workspaceState, }; // 导出提示词 @@ -534,7 +564,7 @@ class BackupRestoreService { const result: ImportResult = { success: false, prompts: { imported: 0, skipped: 0 }, - projects: { folders: 0, boards: 0, skipped: 0 }, + projects: { folders: 0, boards: 0, merged: 0, skipped: 0 }, assets: { imported: 0, skipped: 0 }, tasks: { imported: 0, skipped: 0 }, errors: [], @@ -591,6 +621,11 @@ class BackupRestoreService { await workspaceService.reload(); } + // 返回备份时的工作区状态(用于恢复画布位置) + if (manifest.workspaceState) { + result.workspaceState = manifest.workspaceState; + } + result.success = result.errors.length === 0; onProgress?.(100, '导入完成'); } catch (error: unknown) { @@ -827,9 +862,10 @@ class BackupRestoreService { private async importProjects( zip: JSZip, onProgress?: ProgressCallback - ): Promise<{ folders: number; boards: number; skipped: number }> { + ): Promise<{ folders: number; boards: number; merged: number; skipped: number }> { let foldersImported = 0; let boardsImported = 0; + let boardsMerged = 0; let skipped = 0; // 获取现有数据 @@ -846,7 +882,7 @@ class BackupRestoreService { ); if (drawnixFiles.length === 0) { - return { folders: 0, boards: 0, skipped: 0 }; + return { folders: 0, boards: 0, merged: 0, skipped: 0 }; } // 从目录结构推断文件夹 @@ -918,12 +954,6 @@ class BackupRestoreService { const boardMeta = drawnixData.boardMeta; - // 检查是否已存在 - if (boardMeta?.id && existingBoardIds.has(boardMeta.id)) { - skipped++; - continue; - } - // 推断文件夹 ID const relativePath = filePath.replace(/^projects\//, ''); const parts = relativePath.split('/'); @@ -936,6 +966,51 @@ class BackupRestoreService { const fileName = parts[parts.length - 1] || 'unnamed.drawnix'; const boardName = boardMeta?.name || fileName.replace('.drawnix', ''); + // 检查是否已存在相同 ID 的画板 + if (boardMeta?.id && existingBoardIds.has(boardMeta.id)) { + // 找到现有画板进行合并 + const existingBoard = existingBoards.find(b => b.id === boardMeta.id); + if (existingBoard) { + // 合并元素:保留现有元素,添加备份中新增的元素(基于ID去重) + const existingElementIds = new Set( + (existingBoard.elements || []) + .map(el => el.id) + .filter((id): id is string => !!id) + ); + + const backupElements = drawnixData.elements || []; + // 过滤出备份中的新元素: + // - 有 ID 的元素:只保留现有画板中不存在的 + // - 无 ID 的元素:全部保留(不会与现有元素冲突) + const newElements = backupElements.filter(el => + el.id ? !existingElementIds.has(el.id) : true + ); + + // 合并后的元素:现有元素 + 备份中新增的元素 + const mergedElements = [ + ...(existingBoard.elements || []), + ...newElements, + ]; + + // 合并画板数据 + const mergedBoard: Board = { + ...existingBoard, + // 合并后的元素(图形可以多,但不能少) + elements: mergedElements, + // 使用备份的 viewport(恢复视图位置) + viewport: drawnixData.viewport || existingBoard.viewport, + // 使用备份的 theme + theme: drawnixData.theme || existingBoard.theme, + // 更新时间 + updatedAt: Date.now(), + }; + + await workspaceStorageService.saveBoard(mergedBoard); + boardsMerged++; + continue; + } + } + const board: Board = { id: boardMeta?.id || this.generateId(), name: boardName, @@ -967,7 +1042,7 @@ class BackupRestoreService { } } - return { folders: foldersImported, boards: boardsImported, skipped }; + return { folders: foldersImported, boards: boardsImported, merged: boardsMerged, skipped }; } /** From 8181a3f572881e385fb97d7a37467bc3482c85dd Mon Sep 17 00:00:00 2001 From: liquidliang Date: Fri, 16 Jan 2026 15:41:54 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(backup):=20=E4=BF=AE=E5=A4=8D=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E6=81=A2=E5=A4=8D=E6=9C=8D=E5=8A=A1=E7=9A=84=20TypeSc?= =?UTF-8?q?ript=20=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导入 PlaitTheme 和 CorePlaitElement 类型 - 修改 DrawnixFileData.theme 类型从 string 改为 PlaitTheme - 添加 ensureElementIds 函数确保导入的元素都有 id - 修复 elements 赋值时的类型兼容性问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/services/backup-restore-service.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/drawnix/src/services/backup-restore-service.ts b/packages/drawnix/src/services/backup-restore-service.ts index 9f542b5c..0780d291 100644 --- a/packages/drawnix/src/services/backup-restore-service.ts +++ b/packages/drawnix/src/services/backup-restore-service.ts @@ -131,7 +131,7 @@ interface DrawnixFileData { source: string; elements: PlaitElement[]; viewport: Viewport; - theme?: string; + theme?: PlaitTheme; boardMeta?: { id: string; name: string; @@ -157,6 +157,19 @@ interface PlaitElement { [key: string]: unknown; } +/** + * 确保所有元素都有 id(为没有 id 的元素生成一个) + * 返回类型兼容 @plait/core 的 PlaitElement[] + */ +function ensureElementIds(elements: PlaitElement[]): CorePlaitElement[] { + return elements.map(el => { + if (!el.id) { + return { ...el, id: `imported-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } as CorePlaitElement; + } + return el as CorePlaitElement; + }); +} + /** * 导入结果 */ @@ -987,9 +1000,10 @@ class BackupRestoreService { ); // 合并后的元素:现有元素 + 备份中新增的元素 + // 使用 ensureElementIds 确保新元素都有 id const mergedElements = [ ...(existingBoard.elements || []), - ...newElements, + ...ensureElementIds(newElements), ]; // 合并画板数据 @@ -1016,7 +1030,7 @@ class BackupRestoreService { name: boardName, folderId, order: boardMeta?.order ?? i, - elements: drawnixData.elements || [], + elements: ensureElementIds(drawnixData.elements || []), viewport: drawnixData.viewport, theme: drawnixData.theme, createdAt: boardMeta?.createdAt || Date.now(),