import type { Plugin } from 'vite'; import path from 'path'; import fs from 'fs'; import { IncomingMessage } from 'http'; import formidable from 'formidable'; import extractZip from 'extract-zip'; import archiver from 'archiver'; import { allowedItemKeysByTab, scanEntries, type SidebarTreeTab } from './utils/entryScanner'; import { createSidebarTreeStore, type SidebarTreeNode, type ResourceOrderType } from './utils/sidebarTreeStore'; import { buildAttachmentContentDisposition } from './utils/contentDisposition'; import { ensureTemplatesDirMigrated, getTemplatesDir } from './utils/docUtils'; import { getInstallSkillTargetDir } from './utils/installSkillTargets'; import { runCommand, runCommandSync } from '../scripts/utils/command-runtime.mjs'; /** * 递归复制目录(用于 Windows 权限问题的备用方案) * * 当 fs.renameSync() 因权限问题失败时(EPERM 错误),使用此函数作为 fallback。 * * 为什么 copy 比 rename 更可靠? * - rename:只修改文件系统元数据(inode),对权限和文件占用非常敏感 * - copy:实际读取和写入数据,只要文件可读就能复制,绕过了很多权限限制 * * 常见触发场景: * - Windows 杀毒软件扫描导致文件被锁定 * - 跨驱动器移动文件(rename 不支持) * - 文件索引服务占用文件句柄 * - 路径包含中文字符导致的编码问题 * * @param src - 源目录路径 * @param dest - 目标目录路径 */ function copyDirRecursive(src: string, dest: string) { // 确保目标目录存在 if (!fs.existsSync(dest)) { fs.mkdirSync(dest, { recursive: true }); } // 读取源目录的所有内容 const entries = fs.readdirSync(src, { withFileTypes: true }); // 逐个处理文件和子目录 for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { // 递归处理子目录 copyDirRecursive(srcPath, destPath); } else { // 复制文件 fs.copyFileSync(srcPath, destPath); } } } const IGNORED_EXTRACT_ENTRIES = new Set(['__MACOSX', '.DS_Store']); function truncateName(name: string, maxLength: number) { return name.length > maxLength ? name.slice(0, maxLength) : name; } function sanitizeFolderName(name: string) { return name .replace(/[^a-z0-9-]/gi, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); } function inferExtractedRootFolder(extractDir: string) { if (!fs.existsSync(extractDir)) { return { entryCount: 0, hasRootFolder: false, rootFolderName: '' }; } const entries = fs .readdirSync(extractDir, { withFileTypes: true }) .filter(entry => !IGNORED_EXTRACT_ENTRIES.has(entry.name)); if (entries.length === 1 && entries[0].isDirectory()) { return { entryCount: entries.length, hasRootFolder: true, rootFolderName: entries[0].name }; } return { entryCount: entries.length, hasRootFolder: false, rootFolderName: '' }; } function sanitizeRelativePath(input: string) { const normalized = input.replace(/\\/g, '/'); const parts = normalized.split('/').filter(part => part && part !== '.' && part !== '..'); return parts.join('/'); } function deriveRootFolderName(paths: string[]) { const roots = new Set(); for (const rawPath of paths) { const cleaned = sanitizeRelativePath(rawPath); if (!cleaned) continue; const [root] = cleaned.split('/'); if (root) roots.add(root); } return roots.size === 1 ? Array.from(roots)[0] : ''; } function hasIgnoredEntry(relativePath: string) { return relativePath.split('/').some(segment => IGNORED_EXTRACT_ENTRIES.has(segment)); } function moveFileWithFallback(srcPath: string, destPath: string) { try { fs.renameSync(srcPath, destPath); } catch { fs.copyFileSync(srcPath, destPath); fs.unlinkSync(srcPath); } } const SUPPORTED_UPLOAD_TARGET_TYPES = ['prototypes', 'components', 'themes'] as const; const THEME_IMPORT_SUPPORTED_UPLOAD_TYPES = new Set(['local_axure', 'v0', 'google_aistudio', 'figma_make']); const THEME_IMPORT_SUB_SKILL_DOCS = [ '/skills/axure-prototype-workflow/theme-generation.md', '/skills/axure-prototype-workflow/doc-generation.md', '/skills/axure-prototype-workflow/data-generation.md', '/skills/web-page-workflow/theme-generation.md', '/skills/web-page-workflow/doc-generation.md', '/skills/web-page-workflow/data-generation.md', ]; function formatReferenceList(referencePaths: string[]) { return referencePaths.map((referencePath) => `- \`${referencePath}\``).join('\n'); } /** * 文件系统 API 插件 * 提供文件和目录的基本操作功能:删除、重命名、复制等 */ export function fileSystemApiPlugin(): Plugin { return { name: 'filesystem-api', configureServer(server) { const projectRoot = process.cwd(); const nodeCommand = process.execPath; const entriesPath = path.join(projectRoot, '.axhub', 'make', 'entries.json'); const configPath = path.join(projectRoot, '.axhub', 'make', 'axhub.config.json'); const DEFAULT_PROJECT_TITLE = '未命名项目'; const SIDEBAR_TREE_VERSION = 1; const templateMigrationResult = ensureTemplatesDirMigrated(projectRoot); if (templateMigrationResult.conflicts.length > 0) { console.error( '[filesystem-api] Template migration conflicts detected:\n' + templateMigrationResult.conflicts .map((conflict) => `- ${conflict.relativePath}\n legacy: ${conflict.legacyPath}\n target: ${conflict.targetPath}`) .join('\n'), ); } const sidebarTreeStore = createSidebarTreeStore(projectRoot, { version: SIDEBAR_TREE_VERSION, legacyEntriesPath: entriesPath, }); const isSidebarTreeTab = (value: string): value is SidebarTreeTab => { return value === 'prototypes' || value === 'components' || value === 'docs' || value === 'canvas'; }; const getTabFromRequest = (req: any): SidebarTreeTab | null => { try { const url = new URL(req.url || '/', 'http://localhost'); const tab = (url.searchParams.get('tab') || '').trim(); if (!isSidebarTreeTab(tab)) return null; return tab; } catch { return null; } }; const toDefaultTreeTitle = (itemKey: string) => { const name = itemKey.split('/').pop() || itemKey; return name .replace(/[-_]+/g, ' ') .replace(/\s+/g, ' ') .trim() || name; }; const sanitizeNodeId = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '-'); const buildDefaultSidebarTree = (allowedItemKeys: Set): SidebarTreeNode[] => { const keys = Array.from(allowedItemKeys).sort((a, b) => a.localeCompare(b)); return keys.map((itemKey) => ({ id: `item-${sanitizeNodeId(itemKey)}`, kind: 'item' as const, title: toDefaultTreeTitle(itemKey), itemKey, })); }; const normalizeAndValidateSidebarTree = ( tree: unknown, tab: SidebarTreeTab, allowedItemKeys: Set, ): { valid: true; tree: SidebarTreeNode[] } | { valid: false; error: string } => { if (!Array.isArray(tree)) { return { valid: false, error: 'tree must be an array' }; } const usedIds = new Set(); const seenItemKeys = new Set(); const makeUniqueId = (seed: string) => { let candidate = seed; let count = 1; while (usedIds.has(candidate)) { count += 1; candidate = `${seed}-${count}`; } usedIds.add(candidate); return candidate; }; const normalizeNodes = (nodes: any[], depth: number): SidebarTreeNode[] | null => { if (depth > 32) { return null; } const normalized: SidebarTreeNode[] = []; for (const rawNode of nodes) { if (!rawNode || typeof rawNode !== 'object') { return null; } const id = typeof rawNode.id === 'string' ? rawNode.id.trim() : ''; const kind = rawNode.kind; const title = typeof rawNode.title === 'string' ? rawNode.title.trim() : ''; if (!id || !title) return null; if (kind !== 'folder' && kind !== 'item') { return null; } const nextId = makeUniqueId(id); if (kind === 'item') { const itemKey = typeof rawNode.itemKey === 'string' ? rawNode.itemKey.trim() : ''; if (!itemKey || !itemKey.startsWith(`${tab}/`) || !allowedItemKeys.has(itemKey)) { return null; } if (seenItemKeys.has(itemKey)) { continue; } seenItemKeys.add(itemKey); normalized.push({ id: nextId, kind: 'item', title, itemKey, }); continue; } const rawChildren = Array.isArray(rawNode.children) ? rawNode.children : []; const children = normalizeNodes(rawChildren, depth + 1); if (!children) { return null; } const rawItemKey = typeof rawNode.itemKey === 'string' ? rawNode.itemKey.trim() : ''; const itemKey = rawItemKey && rawItemKey.startsWith(`${tab}/`) && allowedItemKeys.has(rawItemKey) ? rawItemKey : undefined; if (itemKey) { seenItemKeys.add(itemKey); } normalized.push({ id: nextId, kind: 'folder', title, ...(itemKey ? { itemKey } : {}), children, }); } return normalized; }; const normalizedTree = normalizeNodes(tree as any[], 0); if (!normalizedTree) { return { valid: false, error: 'Invalid tree payload' }; } return { valid: true, tree: normalizedTree }; }; const reconcileSidebarTree = ( tree: SidebarTreeNode[], tab: SidebarTreeTab, allowedItemKeys: Set, ): SidebarTreeNode[] => { const usedIds = new Set(); const seenItemKeys = new Set(); const makeUniqueId = (seed: string) => { let candidate = seed; let count = 1; while (usedIds.has(candidate)) { count += 1; candidate = `${seed}-${count}`; } usedIds.add(candidate); return candidate; }; const normalizeNodes = (nodes: SidebarTreeNode[], depth: number): SidebarTreeNode[] => { if (!Array.isArray(nodes) || depth > 32) return []; const result: SidebarTreeNode[] = []; for (const rawNode of nodes) { if (!rawNode || typeof rawNode !== 'object') continue; const title = typeof rawNode.title === 'string' ? rawNode.title.trim() : ''; if (!title) continue; const rawId = typeof rawNode.id === 'string' ? rawNode.id.trim() : ''; const id = makeUniqueId(rawId || `node-${Date.now()}`); if (rawNode.kind === 'item') { const itemKey = typeof rawNode.itemKey === 'string' ? rawNode.itemKey.trim() : ''; if (!itemKey || !itemKey.startsWith(`${tab}/`) || !allowedItemKeys.has(itemKey)) { continue; } if (seenItemKeys.has(itemKey)) { continue; } seenItemKeys.add(itemKey); result.push({ id, kind: 'item', title, itemKey }); continue; } if (rawNode.kind === 'folder') { const children = normalizeNodes(Array.isArray(rawNode.children) ? rawNode.children : [], depth + 1); const rawFolderItemKey = typeof rawNode.itemKey === 'string' ? rawNode.itemKey.trim() : ''; const folderItemKey = rawFolderItemKey && rawFolderItemKey.startsWith(`${tab}/`) && allowedItemKeys.has(rawFolderItemKey) ? rawFolderItemKey : undefined; const folderNode: SidebarTreeNode = { id, kind: 'folder' as const, title, children }; if (folderItemKey) { seenItemKeys.add(folderItemKey); folderNode.itemKey = folderItemKey; } result.push(folderNode); } } return result; }; const normalizedTree = normalizeNodes(tree, 0); const missingItemKeys = Array.from(allowedItemKeys).filter((itemKey) => !seenItemKeys.has(itemKey)); const nextMissingNodes = missingItemKeys.sort((a, b) => a.localeCompare(b)).map((itemKey) => ({ id: makeUniqueId(`item-${sanitizeNodeId(itemKey)}`), kind: 'item' as const, title: toDefaultTreeTitle(itemKey), itemKey, })); return [...nextMissingNodes, ...normalizedTree]; }; const collectSidebarTreeIds = (nodes: SidebarTreeNode[]): Set => { const ids = new Set(); const walk = (list: SidebarTreeNode[]) => { for (const node of list) { if (!node || typeof node !== 'object') continue; const id = typeof node.id === 'string' ? node.id.trim() : ''; if (id) { ids.add(id); } if (Array.isArray(node.children) && node.children.length > 0) { walk(node.children); } } }; walk(nodes); return ids; }; const createUniqueFolderNodeId = (existingIds: Set) => { let candidate = ''; do { const randomSuffix = Math.random().toString(36).slice(2, 8); candidate = `folder-${Date.now()}-${randomSuffix}`; } while (existingIds.has(candidate)); return candidate; }; const createRootFolderTitle = (nodes: SidebarTreeNode[]) => { const rootFolderTitles = new Set(); for (const node of nodes) { if (node.kind !== 'folder') continue; const title = typeof node.title === 'string' ? node.title.trim() : ''; if (!title) continue; rootFolderTitles.add(title); } const defaultTitle = '新建文件夹'; if (!rootFolderTitles.has(defaultTitle)) { return defaultTitle; } let suffix = 2; while (rootFolderTitles.has(`${defaultTitle}-${suffix}`)) { suffix += 1; } return `${defaultTitle}-${suffix}`; }; const readProjectTitle = (): string => { if (!fs.existsSync(configPath)) { return DEFAULT_PROJECT_TITLE; } try { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw); const title = typeof parsed?.projectInfo?.name === 'string' ? parsed.projectInfo.name.trim() : ''; return title || DEFAULT_PROJECT_TITLE; } catch { return DEFAULT_PROJECT_TITLE; } }; const DOC_EXTENSIONS = new Set(['.md', '.csv', '.json', '.yaml', '.yml', '.txt']); const CANVAS_EXT = '.excalidraw'; const collectDocItemKeys = (): Set => { const docsDir = path.join(projectRoot, 'src', 'docs'); const keys: string[] = []; if (!fs.existsSync(docsDir)) { return new Set(); } const walk = (currentDir: string) => { const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const absolutePath = path.join(currentDir, entry.name); if (entry.isDirectory()) { walk(absolutePath); continue; } if (!entry.isFile()) continue; const ext = path.extname(entry.name).toLowerCase(); if (!DOC_EXTENSIONS.has(ext)) continue; const rel = normalizePath(path.relative(docsDir, absolutePath)); keys.push(`docs/${rel}`); } }; walk(docsDir); keys.sort((a, b) => a.localeCompare(b)); return new Set(keys); }; const collectCanvasItemKeys = (): Set => { const canvasDir = path.join(projectRoot, 'src', 'canvas'); const keys: string[] = []; if (!fs.existsSync(canvasDir)) { return new Set(); } const entries = fs.readdirSync(canvasDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(CANVAS_EXT)) continue; keys.push(`canvas/${entry.name}`); } keys.sort((a, b) => a.localeCompare(b)); return new Set(keys); }; const resolveAllowedItemKeys = (tab: SidebarTreeTab): Set => { if (tab === 'docs') { return collectDocItemKeys(); } if (tab === 'canvas') { return collectCanvasItemKeys(); } const scanned = scanEntries(projectRoot); return allowedItemKeysByTab(scanned.entries.js, tab); }; const isResourceOrderType = (value: string): value is ResourceOrderType => { return value === 'themes' || value === 'data' || value === 'templates'; }; const getResourceOrderTypeFromRequest = (req: any): ResourceOrderType | null => { try { const url = new URL(req.url || '/', 'http://localhost'); const type = (url.searchParams.get('type') || '').trim(); if (!isResourceOrderType(type)) return null; return type; } catch { return null; } }; const collectThemeKeys = (): Set => { const themesDir = path.join(projectRoot, 'src', 'themes'); const keys = new Set(); if (!fs.existsSync(themesDir)) { return keys; } const entries = fs.readdirSync(themesDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; keys.add(entry.name); } return keys; }; const collectDataTableKeys = (): Set => { const databaseDir = path.join(projectRoot, 'src', 'database'); const keys = new Set(); if (!fs.existsSync(databaseDir)) { return keys; } const entries = fs.readdirSync(databaseDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.json')) continue; const fileName = entry.name.replace(/\.json$/i, ''); if (fileName) { keys.add(fileName); } } return keys; }; const resolveAllowedResourceKeys = (type: ResourceOrderType): Set => { if (type === 'themes') { return collectThemeKeys(); } if (type === 'data') { return collectDataTableKeys(); } const templatesDir = getTemplatesDir(projectRoot); const keys = new Set(); if (!fs.existsSync(templatesDir)) { return keys; } const walkTemplatesDir = (dirPath: string) => { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { if (!entry || entry.name.startsWith('.')) continue; const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { walkTemplatesDir(fullPath); continue; } if (!entry.isFile()) continue; const relativePath = path.relative(templatesDir, fullPath).split(path.sep).join('/'); if (relativePath) { keys.add(relativePath); } } }; walkTemplatesDir(templatesDir); return keys; }; const reconcileResourceOrder = (order: string[], allowedKeys: Set): string[] => { const seen = new Set(); const nextOrder: string[] = []; for (const key of order) { if (!allowedKeys.has(key) || seen.has(key)) continue; seen.add(key); nextOrder.push(key); } const remaining = Array.from(allowedKeys).filter((key) => !seen.has(key)); remaining.sort((a, b) => a.localeCompare(b)); return [...remaining, ...nextOrder]; }; // Helper function to parse JSON body const parseBody = (req: any): Promise => { return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk: any) => body += chunk); req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } catch (e) { reject(new Error('Invalid JSON in request body')); } }); req.on('error', reject); }); }; // Helper function to send JSON response const sendJSON = (res: any, statusCode: number, data: any) => { res.statusCode = statusCode; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(data)); }; const normalizePath = (filePath: string) => filePath.split(path.sep).join('/'); const isSafeSrcTargetPath = (targetPath: string) => { return Boolean(targetPath) && !targetPath.includes('..') && !targetPath.startsWith('/') && !path.isAbsolute(targetPath); }; const countFilesRecursive = (dirPath: string): number => { if (!fs.existsSync(dirPath)) { return 0; } let total = 0; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { total += countFilesRecursive(entryPath); } else if (entry.isFile()) { total += 1; } } return total; }; const readJsonFileIfExists = (filePath: string) => { if (!fs.existsSync(filePath)) { return null; } try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (error) { console.warn('[文件系统 API] 读取 JSON 失败:', filePath, error); return null; } }; const createDefaultMakeMeta = (baseName: string) => ({ client_meta: { background_color: { r: 0.96, g: 0.96, b: 0.96, a: 1 }, thumbnail_size: { width: 400, height: 300 }, render_coordinates: { x: 0, y: 0, width: 1280, height: 960 }, }, file_name: baseName, developer_related_links: [], exported_at: new Date().toISOString(), }); const analyzeMakeAssets = (itemDir: string, targetPath: string) => { const canvasFigPath = path.join(itemDir, 'canvas.fig'); const metaJsonPath = path.join(itemDir, 'meta.json'); const aiChatPath = path.join(itemDir, 'ai_chat.json'); const thumbnailPath = path.join(itemDir, 'thumbnail.png'); const manifestPath = path.join(itemDir, 'canvas.code-manifest.json'); const imagesDir = path.join(itemDir, 'images'); const rootIndexPath = path.join(itemDir, 'index.tsx'); const rootStylePath = path.join(itemDir, 'style.css'); const appTsxPath = path.join(itemDir, 'src', 'App.tsx'); const indexCssPath = path.join(itemDir, 'src', 'index.css'); const meta = readJsonFileIfExists(metaJsonPath); const baseName = path.basename(targetPath) || 'project'; const rawFileName = typeof meta?.file_name === 'string' ? meta.file_name.trim() : ''; const normalizedFileName = rawFileName || baseName; const imageCount = countFilesRecursive(imagesDir); const driftReasons: string[] = []; if (fs.existsSync(rootIndexPath) && fs.existsSync(appTsxPath)) { const rootIndexStat = fs.statSync(rootIndexPath); const appTsxStat = fs.statSync(appTsxPath); if (rootIndexStat.mtimeMs > appTsxStat.mtimeMs + 1000) { driftReasons.push('根目录 index.tsx 比 src/App.tsx 更新,Figma 导出壳子可能未同步最新页面逻辑。'); } } if (fs.existsSync(rootStylePath) && fs.existsSync(indexCssPath)) { const rootStyleStat = fs.statSync(rootStylePath); const indexCssStat = fs.statSync(indexCssPath); if (rootStyleStat.mtimeMs > indexCssStat.mtimeMs + 1000) { driftReasons.push('根目录 style.css 比 src/index.css 更新,Figma 导出壳子的样式可能未同步。'); } } return { hasCanvasFig: fs.existsSync(canvasFigPath), hasMetaJson: fs.existsSync(metaJsonPath), hasAiChat: fs.existsSync(aiChatPath), hasThumbnail: fs.existsSync(thumbnailPath), hasManifest: fs.existsSync(manifestPath), hasImagesDir: fs.existsSync(imagesDir), imageCount, hasMakeAssets: fs.existsSync(canvasFigPath), lastExportedAt: typeof meta?.exported_at === 'string' ? meta.exported_at : null, fileName: normalizedFileName.endsWith('.fig') ? normalizedFileName : `${normalizedFileName}.fig`, hasDriftRisk: driftReasons.length > 0, driftReasons, itemDir, canvasFigPath, metaJsonPath, aiChatPath, thumbnailPath, manifestPath, imagesDir, rootIndexPath, rootStylePath, appTsxPath, indexCssPath, meta, }; }; const ensureMakeMeta = (itemDir: string, targetPath: string) => { const snapshot = analyzeMakeAssets(itemDir, targetPath); const baseName = path.basename(targetPath) || 'project'; const existingMeta = snapshot.meta && typeof snapshot.meta === 'object' ? snapshot.meta : {}; const nextMeta = { ...createDefaultMakeMeta(baseName), ...existingMeta, client_meta: { ...createDefaultMakeMeta(baseName).client_meta, ...(existingMeta as any)?.client_meta, }, developer_related_links: Array.isArray((existingMeta as any)?.developer_related_links) ? (existingMeta as any).developer_related_links : [], file_name: typeof (existingMeta as any)?.file_name === 'string' && (existingMeta as any).file_name.trim() ? (existingMeta as any).file_name.trim() : baseName, exported_at: new Date().toISOString(), }; fs.writeFileSync(snapshot.metaJsonPath, JSON.stringify(nextMeta, null, 2), 'utf8'); return nextMeta; }; const ensureMakeAiChat = (itemDir: string) => { const aiChatPath = path.join(itemDir, 'ai_chat.json'); if (!fs.existsSync(aiChatPath)) { fs.writeFileSync(aiChatPath, '{}\n', 'utf8'); } }; const buildMakeExportPrompt = (targetPath: string) => { const itemDir = path.join(projectRoot, 'src', targetPath); const snapshot = analyzeMakeAssets(itemDir, targetPath); const relativeItemDir = normalizePath(path.relative(projectRoot, itemDir)); const relativeCanvasFig = normalizePath(path.relative(projectRoot, snapshot.canvasFigPath)); const relativeMeta = normalizePath(path.relative(projectRoot, snapshot.metaJsonPath)); const relativeManifest = normalizePath(path.relative(projectRoot, snapshot.manifestPath)); const relativeAiChat = normalizePath(path.relative(projectRoot, snapshot.aiChatPath)); const relativeImagesDir = normalizePath(path.relative(projectRoot, snapshot.imagesDir)); const templateCanvasPath = 'scripts/templates/empty-canvas.fig'; const sceneLabel = snapshot.hasCanvasFig ? '场景 A(已有 Figma 导出资产)' : '场景 B(原生 Axhub 页面,需要补齐导出壳子)'; let prompt = `请将当前页面补齐为可导出的 Figma 资产结构,并确保最终可通过 \`/api/export-make?path=${targetPath}\` 下载产物 \`${snapshot.fileName}\`。\n\n`; prompt += `请先阅读以下技能文档:\n`; prompt += `- \`/skills/figma-make-exporter/SKILL.md\`\n`; prompt += `- \`/skills/figma-make-project-converter/SKILL.md\`\n\n`; prompt += `目标目录:\`${relativeItemDir}/\`\n`; prompt += `当前判定:${sceneLabel}\n`; prompt += `最终产物说明:接口最终下载的是原始 \`canvas.fig\`,文件名为 \`${snapshot.fileName}\`,不是 \`.make\` 压缩包。\n\n`; prompt += `当前资产状态:\n`; prompt += `- canvas.fig:${snapshot.hasCanvasFig ? '已存在' : '缺失'}\n`; prompt += `- meta.json:${snapshot.hasMetaJson ? '已存在' : '缺失'}\n`; prompt += `- ai_chat.json:${snapshot.hasAiChat ? '已存在' : '缺失'}\n`; prompt += `- thumbnail.png:${snapshot.hasThumbnail ? '已存在' : '缺失'}\n`; prompt += `- images/:${snapshot.hasImagesDir ? `已存在(${snapshot.imageCount} 个文件)` : '缺失'}\n\n`; if (snapshot.hasDriftRisk) { prompt += `当前检测到导出壳子可能未同步:\n`; snapshot.driftReasons.forEach((reason: string) => { prompt += `- ${reason}\n`; }); prompt += `\n`; } prompt += `执行要求:\n`; prompt += `1. 不要删除已有业务源码,也不要删除任何已存在的 Figma 原始资产(如 \`canvas.fig\`、\`meta.json\`、\`ai_chat.json\`、\`thumbnail.png\`、\`images/\`)。\n`; prompt += `2. 先确保当前 Axhub 页面真实入口 \`index.tsx\` / \`style.css\` 的页面结果已经同步到导出壳子 \`src/App.tsx\` / \`src/index.css\` / \`src/components/**\`。\n`; prompt += `3. 目录结构按固定职责维护:根目录 \`index.tsx\` 仅做 Axhub runtime adapter,\`src/App.tsx\` 仅做 Figma export shell,真实页面主体优先放在 \`src/components/**\` / \`src/styles/**\`。\n`; prompt += `4. 同步时优先把 \`src/App.tsx\` 做成薄壳,尽量直接复用当前页面组件;不要再维护一份容易过时的旧页面副本。\n`; prompt += `5. 如果 \`src/index.css\` 与根目录 \`style.css\` 都存在,优先复用或同步根目录样式来源,避免导出样式与当前页面不一致。\n`; prompt += `6. 在 \`index.tsx\`、\`src/App.tsx\`、\`src/main.tsx\` 顶部补充职责注释,明确哪些文件只能做适配层/挂载层,防止后续继续漂移。\n`; prompt += ` 若最终项目不符合这套固定职责结构,视为任务未完成,必须先重构到位再继续导出。\n`; prompt += ` 导出前还必须同步 \`CODE_FILE.sourceCode\` 与 \`CODE_FILE.collaborativeSourceCode\`,再清理 \`canvas.fig\` 内部旧代码历史:移除过期 \`CODE_LIBRARY.chatMessages\` / \`chatCompressionState\`,清空旧 \`CODE_INSTANCE.codeSnapshot\` 预览缓存,并裁掉悬空的 \`CODE_COMPONENT\` 引用,避免 Figma Make 导入后恢复旧文件树。\n`; if (snapshot.hasCanvasFig) { prompt += `7. 直接复用已有 \`${relativeCanvasFig}\`,运行以下命令把当前源码回写进去:\n`; prompt += ` \`node scripts/canvas-fig-sync.mjs pack --fig ${relativeCanvasFig} --from ${relativeItemDir} --prune-missing --sanitize-for-export\`\n`; } else { prompt += `7. 使用内置空白模板 \`${templateCanvasPath}\` 作为基座,在 \`${relativeCanvasFig}\` 生成新的 \`canvas.fig\`,然后运行:\n`; prompt += ` \`node scripts/canvas-fig-sync.mjs pack --fig ${relativeCanvasFig} --from ${relativeItemDir} --prune-missing --sanitize-for-export\`\n`; } prompt += `8. 生成或更新 \`${relativeManifest}\`:\n`; prompt += ` \`node scripts/canvas-fig-sync.mjs inspect --fig ${relativeCanvasFig} --manifest ${relativeManifest}\`\n`; prompt += `9. 生成或更新 \`${relativeMeta}\`,至少包含 \`file_name\`、\`exported_at\`、\`client_meta\`、\`developer_related_links\`。其中 \`file_name\` 应与最终下载名一致,不要再写成 \`.make\`。\n`; prompt += `10. 确保 \`${relativeAiChat}\` 至少是空 JSON 对象 \`{}\`。\n`; prompt += `11. 如页面依赖图片资源,请把导出所需图片保留或同步到 \`${relativeImagesDir}/\`;不要随意改名已有 hash 文件。\n`; prompt += `12. 如果当前目录缺少导出壳子(如 \`src/App.tsx\`、\`src/main.tsx\`、\`package.json\`、\`vite.config.ts\`、\`index.html\`),请按技能文档补齐到 Figma 兼容结构。\n\n`; prompt += `验收要求:\n`; prompt += `- \`node scripts/canvas-fig-sync.mjs inspect --fig ${relativeCanvasFig}\` 能成功执行\n`; prompt += `- \`${relativeMeta}\` 中 \`exported_at\` 为最新时间\n`; prompt += `- \`${relativeMeta}\` 中 \`file_name\` 对应最终下载文件名 \`${snapshot.fileName}\`\n`; prompt += `- 导出壳子展示结果必须与当前页面一致,不能还是旧的 Figma 壳子内容\n`; prompt += `- ` + '`index.tsx` / `src/App.tsx` / `src/main.tsx`' + ` 顶部存在职责注释,且符合固定目录结构\n`; prompt += `- 再次访问 \`/api/export-make?path=${targetPath}&probe=1\` 时,返回 \`hasMakeAssets: true\`\n`; return prompt; }; server.middlewares.use('/api/prototype-admin/project-title', async (req: any, res: any) => { if (req.method === 'GET') { return sendJSON(res, 200, { title: readProjectTitle() }); } if (req.method !== 'PATCH') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const body = await parseBody(req); const rawTitle = typeof body?.title === 'string' ? body.title : ''; const title = rawTitle.trim(); if (!title) { return sendJSON(res, 400, { error: 'title cannot be empty' }); } if (/[\u0000-\u001F\u007F]/.test(title)) { return sendJSON(res, 400, { error: 'title contains invalid control characters' }); } const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {}; const nextConfig = { ...config, projectInfo: { ...(config?.projectInfo || {}), name: title, }, }; fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2), 'utf8'); return sendJSON(res, 200, { success: true, title }); } catch (e: any) { return sendJSON(res, 500, { error: e?.message || 'Update project title failed' }); } }); // ─── Skill Install API ────────────────────────────────────────────── server.middlewares.use('/api/prototype-admin/install-skill', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const body = await parseBody(req); const skillId = typeof body?.skillId === 'string' ? body.skillId.trim() : ''; const client = typeof body?.client === 'string' ? body.client.trim() : ''; if (!skillId || !client) { return sendJSON(res, 400, { error: 'skillId and client are required' }); } const targetDir = getInstallSkillTargetDir(client); if (!targetDir) { return sendJSON(res, 400, { error: 'not_supported', message: `${client} 暂不支持自动安装技能`, }); } const sourceDir = path.join(projectRoot, 'skills', skillId); if (!fs.existsSync(sourceDir)) { return sendJSON(res, 404, { error: `Skill '${skillId}' not found at skills/${skillId}` }); } const destDir = path.join(projectRoot, targetDir, skillId); fs.mkdirSync(destDir, { recursive: true }); copyDirRecursive(sourceDir, destDir); return sendJSON(res, 200, { success: true, skillId, client, installedTo: `${targetDir}/${skillId}`, }); } catch (e: any) { return sendJSON(res, 500, { error: e?.message || 'Install skill failed' }); } }); server.middlewares.use('/api/prototype-admin/sidebar-tree/folder', async (req: any, res: any) => { const tab = getTabFromRequest(req); if (!tab) { return sendJSON(res, 400, { error: 'Invalid tab, expected prototypes|components|docs|canvas' }); } if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const allowedItemKeys = resolveAllowedItemKeys(tab); const storedTree = sidebarTreeStore.getTree(tab); const sourceTree = storedTree.length > 0 ? storedTree : buildDefaultSidebarTree(allowedItemKeys); const tree = reconcileSidebarTree(sourceTree, tab, allowedItemKeys); const existingIds = collectSidebarTreeIds(tree); const createdFolderId = createUniqueFolderNodeId(existingIds); const title = createRootFolderTitle(tree); const nextTree: SidebarTreeNode[] = [ { id: createdFolderId, kind: 'folder', title, children: [], }, ...tree, ]; sidebarTreeStore.setTree(tab, nextTree); return sendJSON(res, 200, { success: true, tab, version: SIDEBAR_TREE_VERSION, createdFolderId, tree: nextTree, }); } catch (e: any) { return sendJSON(res, 500, { error: e?.message || 'Create sidebar folder failed' }); } }); server.middlewares.use('/api/prototype-admin/sidebar-tree', async (req: any, res: any) => { const tab = getTabFromRequest(req); if (!tab) { return sendJSON(res, 400, { error: 'Invalid tab, expected prototypes|components|docs|canvas' }); } if (req.method === 'GET') { const allowedItemKeys = resolveAllowedItemKeys(tab); const storedTree = sidebarTreeStore.getTree(tab); const sourceTree = storedTree.length > 0 ? storedTree : buildDefaultSidebarTree(allowedItemKeys); const tree = reconcileSidebarTree(sourceTree, tab, allowedItemKeys); if (JSON.stringify(tree) !== JSON.stringify(storedTree)) { sidebarTreeStore.setTree(tab, tree); } return sendJSON(res, 200, { tab, version: SIDEBAR_TREE_VERSION, tree, }); } if (req.method !== 'PUT') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const body = await parseBody(req); const allowedItemKeys = resolveAllowedItemKeys(tab); const normalized = normalizeAndValidateSidebarTree(body?.tree, tab, allowedItemKeys); if (!normalized.valid) { return sendJSON(res, 400, { error: normalized.error }); } sidebarTreeStore.setTree(tab, normalized.tree); return sendJSON(res, 200, { success: true, tab, version: SIDEBAR_TREE_VERSION, tree: normalized.tree, }); } catch (e: any) { return sendJSON(res, 500, { error: e?.message || 'Save sidebar tree failed' }); } }); server.middlewares.use('/api/prototype-admin/resource-order', async (req: any, res: any) => { const type = getResourceOrderTypeFromRequest(req); if (!type) { return sendJSON(res, 400, { error: 'Invalid type, expected themes|data|templates' }); } if (req.method === 'GET') { try { const allowedKeys = resolveAllowedResourceKeys(type); const storedOrder = sidebarTreeStore.getResourceOrder(type); const order = reconcileResourceOrder(storedOrder, allowedKeys); if (JSON.stringify(order) !== JSON.stringify(storedOrder)) { sidebarTreeStore.setResourceOrder(type, order); } return sendJSON(res, 200, { type, version: SIDEBAR_TREE_VERSION, order, }); } catch (e: any) { return sendJSON(res, 500, { error: e?.message || 'Load resource order failed' }); } } if (req.method !== 'PUT') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const body = await parseBody(req); if (!Array.isArray(body?.order)) { return sendJSON(res, 400, { error: 'order must be an array' }); } const requestedOrder = body.order .filter((key: unknown): key is string => typeof key === 'string') .map((key) => key.trim()) .filter(Boolean); const allowedKeys = resolveAllowedResourceKeys(type); const invalidKey = requestedOrder.find((key) => !allowedKeys.has(key)); if (invalidKey) { return sendJSON(res, 400, { error: `Invalid resource key: ${invalidKey}` }); } const order = reconcileResourceOrder(requestedOrder, allowedKeys); sidebarTreeStore.setResourceOrder(type, order); return sendJSON(res, 200, { success: true, type, version: SIDEBAR_TREE_VERSION, order, }); } catch (e: any) { return sendJSON(res, 500, { error: e?.message || 'Save resource order failed' }); } }); const scanThemeReferences = (themeName: string) => { const referenceDirs = [ path.join(projectRoot, 'src', 'components'), path.join(projectRoot, 'src', 'prototypes'), ]; const allowedExt = new Set(['.ts', '.tsx', '.js', '.jsx', '.md', '.css']); const needles = [ `themes/${themeName}/designToken.json`, `themes/${themeName}/globals.css`, ]; const references = new Set(); const walkDir = (dirPath: string) => { if (!fs.existsSync(dirPath)) return; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { walkDir(entryPath); continue; } const ext = path.extname(entry.name); if (!allowedExt.has(ext)) continue; const content = fs.readFileSync(entryPath, 'utf8'); if (needles.some(needle => content.includes(needle))) { references.add(normalizePath(path.relative(projectRoot, entryPath))); } } }; referenceDirs.forEach(walkDir); return Array.from(references).sort(); }; const scanItemReferences = (itemType: 'components' | 'prototypes', itemName: string) => { const referenceDirs = [ path.join(projectRoot, 'src', 'components'), path.join(projectRoot, 'src', 'prototypes'), ]; const allowedExt = new Set(['.ts', '.tsx', '.js', '.jsx', '.md', '.css']); const references = new Set(); const normalizedItemName = String(itemName || '').trim(); const escapedName = normalizedItemName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const nameRegex = new RegExp(`(?:^|[\\\\/])${escapedName}(?:$|[\\\\/'"\\s])`); const targetDir = path.resolve(projectRoot, 'src', itemType, normalizedItemName); const walkDir = (dirPath: string) => { if (!fs.existsSync(dirPath)) return; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { if (path.resolve(entryPath) === targetDir) { continue; } walkDir(entryPath); continue; } const ext = path.extname(entry.name); if (!allowedExt.has(ext)) continue; const content = fs.readFileSync(entryPath, 'utf8'); if (nameRegex.test(content)) { references.add(normalizePath(path.relative(projectRoot, entryPath))); } } }; referenceDirs.forEach(walkDir); return Array.from(references).sort(); }; // 递归复制目录 const copyDir = (src: string, dest: string) => { fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDir(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } }; // ==================== /api/themes/check-references ==================== server.middlewares.use('/api/themes/check-references', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const { themeName } = await parseBody(req); if (!themeName || typeof themeName !== 'string') { return sendJSON(res, 400, { error: 'Missing themeName parameter' }); } const themeDir = path.join(projectRoot, 'src', 'themes', themeName); if (!fs.existsSync(themeDir)) { return sendJSON(res, 404, { error: 'Theme not found' }); } const references = scanThemeReferences(themeName); const designTokenPath = path.join(themeDir, 'designToken.json'); const globalsPath = path.join(themeDir, 'globals.css'); return sendJSON(res, 200, { themeName, references, hasReferences: references.length > 0, themeAssets: { hasDesignToken: fs.existsSync(designTokenPath), hasGlobals: fs.existsSync(globalsPath), }, }); } catch (e: any) { console.error('[文件系统 API] 检查主题引用失败:', e); return sendJSON(res, 500, { error: e.message || 'Check references failed' }); } }); server.middlewares.use('/api/themes/get-contents', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const { themeName } = await parseBody(req); if (!themeName || typeof themeName !== 'string') { return sendJSON(res, 400, { error: 'Missing themeName parameter' }); } const themeDir = path.join(projectRoot, 'src', 'themes', themeName); if (!fs.existsSync(themeDir)) { return sendJSON(res, 404, { error: 'Theme not found' }); } const designTokenPath = path.join(themeDir, 'designToken.json'); const globalsPath = path.join(themeDir, 'globals.css'); const specPath = path.join(themeDir, 'DESIGN-SPEC.md'); return sendJSON(res, 200, { themeName, designToken: fs.existsSync(designTokenPath) ? fs.readFileSync(designTokenPath, 'utf8') : null, globalsCss: fs.existsSync(globalsPath) ? fs.readFileSync(globalsPath, 'utf8') : null, designSpec: fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf8') : null, }); } catch (e: any) { console.error('[文件系统 API] 获取主题内容失败:', e); return sendJSON(res, 500, { error: e.message || 'Get theme contents failed' }); } }); // ==================== /api/items/check-references ==================== server.middlewares.use('/api/items/check-references', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const { itemType, itemName } = await parseBody(req); if (!itemType || !itemName || typeof itemType !== 'string' || typeof itemName !== 'string') { return sendJSON(res, 400, { error: 'Missing itemType or itemName parameter' }); } if (itemType !== 'components' && itemType !== 'prototypes') { return sendJSON(res, 400, { error: 'Invalid itemType' }); } const itemDir = path.join(projectRoot, 'src', itemType, itemName); if (!fs.existsSync(itemDir)) { return sendJSON(res, 404, { error: 'Item not found' }); } const references = scanItemReferences(itemType, itemName); return sendJSON(res, 200, { itemType, itemName, references, hasReferences: references.length > 0, }); } catch (e: any) { console.error('[文件系统 API] 检查元素/页面引用失败:', e); return sendJSON(res, 500, { error: e.message || 'Check references failed' }); } }); // ==================== /api/delete ==================== server.middlewares.use('/api/delete', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const { path: targetPath } = await parseBody(req); if (!targetPath) { return sendJSON(res, 400, { error: 'Missing path parameter' }); } // 验证路径安全性 if (targetPath.includes('..') || targetPath.startsWith('/')) { return sendJSON(res, 403, { error: 'Invalid path' }); } const parts = String(targetPath).split('/').filter(Boolean); const isElementsOrPages = parts.length === 2 && (parts[0] === 'components' || parts[0] === 'prototypes'); const deletePath = isElementsOrPages ? path.join(projectRoot, 'src', parts[0], parts[1]) : path.join(projectRoot, 'src', targetPath); if (!fs.existsSync(deletePath)) { return sendJSON(res, 404, { error: 'Directory not found' }); } if (isElementsOrPages) { fs.rmSync(deletePath, { recursive: true, force: true }); return sendJSON(res, 200, { success: true, deletedPaths: [targetPath], }); } // 删除目录 fs.rmSync(deletePath, { recursive: true, force: true }); return sendJSON(res, 200, { success: true }); } catch (e: any) { console.error('[文件系统 API] 删除失败:', e); return sendJSON(res, 500, { error: e.message || 'Delete failed' }); } }); // ==================== /api/rename ==================== server.middlewares.use('/api/rename', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const { path: targetPath, newName } = await parseBody(req); if (!targetPath || !newName) { return sendJSON(res, 400, { error: 'Missing path or newName parameter' }); } // 验证路径安全性 if (targetPath.includes('..') || targetPath.startsWith('/')) { return sendJSON(res, 403, { error: 'Invalid path' }); } // 验证新名称格式 const trimmedNewName = String(newName).trim(); if (!trimmedNewName) { return sendJSON(res, 400, { error: 'Invalid newName format' }); } if (trimmedNewName === '.' || trimmedNewName === '..') { return sendJSON(res, 400, { error: 'Invalid newName format' }); } if (/[\r\n]/.test(trimmedNewName)) { return sendJSON(res, 400, { error: 'Invalid newName format' }); } if (trimmedNewName.includes('*/')) { return sendJSON(res, 400, { error: 'Invalid newName format' }); } if (/[/\\:*?"<>|]/.test(trimmedNewName)) { return sendJSON(res, 400, { error: 'Invalid newName format' }); } // 解析路径 const parts = String(targetPath).split('/').filter(Boolean); if (parts.length !== 2 || (parts[0] !== 'components' && parts[0] !== 'prototypes')) { return sendJSON(res, 400, { error: 'Invalid path format' }); } const group = parts[0]; const itemName = parts[1]; const itemDir = path.join(projectRoot, 'src', group, itemName); if (!fs.existsSync(itemDir)) { return sendJSON(res, 404, { error: 'Directory not found' }); } const indexFiles = ['index.tsx', 'index.ts', 'index.jsx', 'index.js']; let indexFilePath: string | null = null; for (const fileName of indexFiles) { const filePath = path.join(itemDir, fileName); if (fs.existsSync(filePath)) { indexFilePath = filePath; break; } } if (!indexFilePath) { return sendJSON(res, 404, { error: 'Entry file not found' }); } const nameLineRegex = /(^\s*\*\s*@(?:name|displayName)\s+)(.+)$/m; const content = fs.readFileSync(indexFilePath, 'utf8'); let updatedContent = content; if (nameLineRegex.test(content)) { updatedContent = content.replace(nameLineRegex, `$1${trimmedNewName}`); } else { updatedContent = `/**\n * @name ${trimmedNewName}\n */\n${content}`; } if (updatedContent !== content) { fs.writeFileSync(indexFilePath, updatedContent, 'utf8'); } sendJSON(res, 200, { success: true }); } catch (e: any) { console.error('[文件系统 API] 重命名失败:', e); sendJSON(res, 500, { error: e.message || 'Rename failed' }); } }); // ==================== /api/upload ==================== server.middlewares.use('/api/upload', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const form = formidable({ uploadDir: path.join(projectRoot, 'temp'), keepExtensions: true, multiples: true, maxFileSize: 100 * 1024 * 1024, // 100MB }); form.parse(req, async (err: any, fields: any, files: any) => { if (err) { console.error('[文件系统 API] 上传解析失败:', err); return sendJSON(res, 500, { error: 'Upload parsing failed' }); } try { // 提取字段值(处理数组和单值) const getFieldValue = (field: any) => Array.isArray(field) ? field[0] : field; const uploadType = getFieldValue(fields.uploadType); const targetType = getFieldValue(fields.targetType); const uploadMode = getFieldValue(fields.uploadMode); const folderNameField = getFieldValue(fields.folderName); const targetTypeRequired = uploadType !== 'local_axure'; const normalizeFiles = (value: any) => { if (!value) return []; return Array.isArray(value) ? value : [value]; }; let fileList = normalizeFiles(files.files); if (fileList.length === 0) fileList = normalizeFiles(files.file); if (fileList.length === 0 && fields.file) { fileList = normalizeFiles(fields.file); } const isFolderUpload = uploadMode === 'folder' || fileList.length > 1; console.log('[文件系统 API] 原始文件对象:', { hasFilesFile: !!files.file, hasFilesFiles: !!files.files, hasFieldsFile: !!fields.file, fileCount: fileList.length, uploadMode, isFolderUpload, }); console.log('[文件系统 API] 接收到的参数:', { uploadType, targetType, hasFile: fileList.length > 0, fileInfo: fileList.length > 0 ? { filepath: fileList[0]?.filepath, originalFilename: fileList[0]?.originalFilename } : null, fieldsKeys: Object.keys(fields), filesKeys: Object.keys(files) }); if (!fileList.length || !uploadType || (targetTypeRequired && !targetType)) { console.error('[文件系统 API] 缺少必需参数:', { hasFile: fileList.length > 0, uploadType, targetType, fileType: fileList.length > 0 ? typeof fileList[0] : 'undefined' }); return sendJSON(res, 400, { error: 'Missing required parameters', details: { hasFile: fileList.length > 0, hasUploadType: !!uploadType, hasTargetType: !!targetType, targetTypeRequired } }); } if ( targetTypeRequired && !SUPPORTED_UPLOAD_TARGET_TYPES.includes(String(targetType) as (typeof SUPPORTED_UPLOAD_TARGET_TYPES)[number]) ) { return sendJSON(res, 400, { error: `Invalid targetType: ${targetType}. Supported targetType: ${SUPPORTED_UPLOAD_TARGET_TYPES.join(', ')}` }); } if (targetType === 'themes' && !THEME_IMPORT_SUPPORTED_UPLOAD_TYPES.has(String(uploadType))) { return sendJSON(res, 400, { error: `uploadType=${uploadType} 暂不支持 targetType=themes。当前支持: ${Array.from(THEME_IMPORT_SUPPORTED_UPLOAD_TYPES).join(', ')}` }); } const primaryFile = fileList[0]; const tempFilePath = primaryFile?.filepath || primaryFile?.path || primaryFile?.tempFilePath; const originalFilename = primaryFile?.originalFilename || primaryFile?.name || primaryFile?.filename || 'upload.zip'; console.log('[文件系统 API] 文件信息:', { tempFilePath, originalFilename, fileCount: fileList.length, isFolderUpload, }); if (uploadType === 'figma_make') { if (isFolderUpload) { return sendJSON(res, 400, { error: 'figma_make 仅支持上传 Figma 原始导出的 ZIP 工程包,请不要上传文件夹' }); } if (!String(originalFilename).toLowerCase().endsWith('.zip')) { return sendJSON(res, 400, { error: 'figma_make 仅支持 ZIP 文件,请上传 Figma 原始导出的 ZIP 工程包' }); } } if (!isFolderUpload) { if (!tempFilePath || !fs.existsSync(tempFilePath)) { return sendJSON(res, 500, { error: '临时文件不存在' }); } if (fs.statSync(tempFilePath).size === 0) { return sendJSON(res, 500, { error: '上传的文件为空' }); } } else { const missingFile = fileList.find((file: any) => !file?.filepath || !fs.existsSync(file.filepath)); if (missingFile) { return sendJSON(res, 500, { error: '上传的文件夹存在缺失文件,请重试' }); } } const relativePaths = normalizeFiles(fields.relativePaths).map((value: any) => String(value)); const derivedRootName = deriveRootFolderName(relativePaths); // AI 辅助类型:local_axure(解压到 temp 并返回 Prompt) if (uploadType === 'local_axure') { if (isFolderUpload) { return sendJSON(res, 400, { error: 'local_axure 暂不支持文件夹上传,请使用 ZIP 文件' }); } try { const scriptPath = path.join(projectRoot, 'scripts', 'local-axure-extract.mjs'); const commandResult = runCommandSync({ command: nodeCommand, args: [scriptPath, tempFilePath, originalFilename], cwd: projectRoot, }); if (commandResult.status !== 0) { const details = [commandResult.stderr, commandResult.stdout] .filter(Boolean) .join('\n') .trim(); throw new Error(details || 'local-axure-extract failed'); } const rawOutput = commandResult.stdout.trim(); const lastLine = rawOutput.split('\n').filter(Boolean).slice(-1)[0] || rawOutput; const extracted = JSON.parse(lastLine) as { extractDir: string; contentDir?: string }; const filePath = extracted.contentDir || extracted.extractDir; // 清理临时 zip fs.unlinkSync(tempFilePath); const isThemeImport = targetType === 'themes'; const skillDocs = isThemeImport ? ['/skills/local-axure-workflow/SKILL.md', ...THEME_IMPORT_SUB_SKILL_DOCS] : ['/skills/local-axure-workflow/SKILL.md']; const targetHint = targetType ? `\n\n建议输出目录:\`src/${targetType}\`` : ''; const prompt = isThemeImport ? `本地 Axure ZIP 已上传并解压完成。\n\n解压目录:\`${filePath}\`\n\n请阅读技能文档:\n${formatReferenceList(skillDocs)}\n\n目标:导入主题并生成主题/文档/数据相关资产。\n\n建议输出目录:\n- \`src/themes//\`\n- \`src/docs/\`\n- \`src/database/\`\n\n开始执行前:先根据 skill 的用户交互指南用简短中文回复用户,确认需求(主题范围/是否需要文档与数据/是否允许优化)。\n\n请按技能文档流程,从解压目录中提取并生成主题 token、设计规范、项目文档与数据模型。` : `本地 Axure ZIP 已上传并解压完成。\n\n解压目录:\`${filePath}\`\n\n请阅读技能文档:\n${formatReferenceList(skillDocs)}${targetHint}\n\n开始执行前:先根据 skill 的用户交互指南用简短中文回复用户,确认需求(目标范围/输出类型/是否允许优化等)。\n\n请按技能文档流程,从解压目录中提取主题/数据/文档并还原页面/元素。`; return sendJSON(res, 200, { success: true, uploadType, filePath, prompt, message: '文件已解压,请复制 Prompt 交给 AI 处理' }); } catch (e: any) { console.error('[文件系统 API] local_axure 解压失败:', e); return sendJSON(res, 500, { error: `解压失败: ${e.message}` }); } } let folderUploadContext: { tempExtractDir: string; inferred: { entryCount: number; hasRootFolder: boolean; rootFolderName: string }; fallbackName: string; } | null = null; if (isFolderUpload) { try { const tempExtractDir = path.join(projectRoot, 'temp', `folder-upload-${Date.now()}`); fs.mkdirSync(tempExtractDir, { recursive: true }); const fallbackSource = folderNameField || derivedRootName || `upload-${Date.now()}`; const fallbackName = truncateName(sanitizeFolderName(fallbackSource), 60) || `upload-${Date.now()}`; fileList.forEach((file: any, index: number) => { const sourcePath = file?.filepath || file?.path || file?.tempFilePath; if (!sourcePath || !fs.existsSync(sourcePath)) return; const rawRelativePath = relativePaths[index] || file?.originalFilename || file?.name || `file-${index}`; const safeRelativePath = sanitizeRelativePath(String(rawRelativePath)); if (!safeRelativePath || hasIgnoredEntry(safeRelativePath)) { fs.unlinkSync(sourcePath); return; } const destPath = path.join(tempExtractDir, safeRelativePath); fs.mkdirSync(path.dirname(destPath), { recursive: true }); fs.copyFileSync(sourcePath, destPath); fs.unlinkSync(sourcePath); }); const inferred = inferExtractedRootFolder(tempExtractDir); if (inferred.entryCount === 0) { return sendJSON(res, 500, { error: '上传的文件夹为空' }); } folderUploadContext = { tempExtractDir, inferred, fallbackName }; } catch (e: any) { console.error('[文件系统 API] 文件夹处理失败:', e); return sendJSON(res, 500, { error: `文件夹处理失败: ${e.message || '未知错误'}` }); } } // 直接处理类型:make, axhub, google_stitch if (['make', 'axhub', 'google_stitch'].includes(uploadType)) { try { // 解压到临时目录(先解压再分析目录结构,避免依赖 ZIP 条目解析) const tempExtractDir = isFolderUpload ? folderUploadContext!.tempExtractDir : path.join(projectRoot, 'temp', `extract-${Date.now()}`); if (!isFolderUpload) { fs.mkdirSync(tempExtractDir, { recursive: true }); await extractZip(tempFilePath, { dir: tempExtractDir }); } const inferred = isFolderUpload ? folderUploadContext!.inferred : inferExtractedRootFolder(tempExtractDir); if (inferred.entryCount === 0) { throw new Error('ZIP 文件为空'); } const extractedRootFolderName = inferred.rootFolderName; const hasRootFolder = inferred.hasRootFolder; const basename = isFolderUpload ? folderUploadContext!.fallbackName : path.basename(originalFilename, path.extname(originalFilename)); const fallbackFolderName = truncateName(sanitizeFolderName(basename), 60); const safeFallbackFolderName = fallbackFolderName || `upload-${Date.now()}`; const targetFolderName = hasRootFolder ? truncateName(extractedRootFolderName, 60) : safeFallbackFolderName; const targetBaseDir = path.join(projectRoot, 'src', targetType); const targetDir = path.join(targetBaseDir, targetFolderName); const resolvedTargetBase = path.resolve(targetBaseDir); const resolvedTargetDir = path.resolve(targetDir); // 防止覆盖整个 prototypes/components 目录或越界写入 if (resolvedTargetDir === resolvedTargetBase || !resolvedTargetDir.startsWith(resolvedTargetBase + path.sep)) { throw new Error('目标目录不安全,已阻止解压'); } console.log('[文件系统 API] ZIP 结构分析:', { hasRootFolder, rootFolderName: extractedRootFolderName, targetDir, entriesCount: inferred.entryCount }); // 如果目标目录已存在,直接删除(覆盖) if (fs.existsSync(targetDir)) { fs.rmSync(targetDir, { recursive: true, force: true }); } // 🔧 Windows 兼容性修复:等待杀毒软件释放文件 // 在 Windows 上,解压后杀毒软件(如 Windows Defender)会立即扫描新文件 // 导致文件被短暂锁定,此时执行 rename 会触发 EPERM 错误 // 延迟 500ms 让杀毒软件完成扫描,大幅降低权限错误的概率 await new Promise(resolve => setTimeout(resolve, 500)); // 移动到目标目录(使用复制+删除方式作为 fallback,避免 Windows 权限问题) if (hasRootFolder) { // 有根目录:移动根目录 const extractedRoot = path.join(tempExtractDir, extractedRootFolderName); if (fs.existsSync(extractedRoot)) { try { // 优先尝试 rename(快速路径,毫秒级完成) // rename 只修改文件系统元数据,不移动实际数据,性能最优 fs.renameSync(extractedRoot, targetDir); } catch (renameError: any) { // rename 失败则使用复制+删除(兼容路径,秒级完成) // 虽然慢,但能处理跨驱动器、权限问题等 rename 无法处理的情况 console.warn('[文件系统] rename 失败,使用复制方式:', renameError.message); copyDirRecursive(extractedRoot, targetDir); fs.rmSync(extractedRoot, { recursive: true, force: true }); } } else { throw new Error('解压后找不到根目录'); } } else { // 没有根目录:直接移动整个解压目录 try { // 优先尝试 rename(快速路径) fs.renameSync(tempExtractDir, targetDir); } catch (renameError: any) { // rename 失败则使用复制+删除(兼容路径) console.warn('[文件系统] rename 失败,使用复制方式:', renameError.message); copyDirRecursive(tempExtractDir, targetDir); fs.rmSync(tempExtractDir, { recursive: true, force: true }); } } // 清理临时文件 if (fs.existsSync(tempExtractDir)) { fs.rmSync(tempExtractDir, { recursive: true, force: true }); } if (!isFolderUpload) { fs.unlinkSync(tempFilePath); } // 根据类型执行转换脚本 if (uploadType === 'axhub') { // Chrome 扩展:执行转换脚本 const scriptPath = path.join(projectRoot, 'scripts', 'chrome-export-converter.mjs'); void runCommand({ command: nodeCommand, args: [scriptPath, targetDir, targetFolderName], cwd: projectRoot, capture: true, }).then((result) => { if (result.code !== 0) { console.error('[Chrome 转换] 执行失败:', result.stderr || result.stdout || `exit=${result.code}`); } else { console.log('[Chrome 转换] 完成:', result.stdout); } if (result.stderr) console.error('[Chrome 转换] 错误:', result.stderr); }).catch((error: any) => { console.error('[Chrome 转换] 执行失败:', error); }); } else if (uploadType === 'google_stitch') { const scriptPath = path.join(projectRoot, 'scripts', 'stitch-converter.mjs'); const commandResult = runCommandSync({ command: nodeCommand, args: [scriptPath, targetDir, targetFolderName], cwd: projectRoot, }); if (commandResult.status !== 0) { throw new Error(commandResult.stderr || commandResult.stdout || `stitch-converter exit=${commandResult.status}`); } const output = commandResult.stdout.trim(); const lastLine = output.split('\n').filter(Boolean).slice(-1)[0] || output; let stitchResult: { success?: boolean; requiresAi?: boolean; prompt?: string | null; reasons?: string[]; } = {}; try { stitchResult = JSON.parse(lastLine); } catch (parseError: any) { throw new Error(`stitch-converter 返回结果无法解析: ${parseError.message}`); } const requiresAi = stitchResult.requiresAi === true; return sendJSON(res, 200, { success: true, message: requiresAi ? '页面已导入完成,可先预览基础效果。部分细节还可继续优化,建议交给 AI 完成。' : '上传并解压成功', folderName: targetFolderName, path: `${targetType}/${targetFolderName}`, hint: requiresAi ? '复制提示词后,可继续完善交互与动态内容' : '如果页面无法预览,让 AI 处理即可', requiresAi, prompt: stitchResult.prompt || undefined, reasons: Array.isArray(stitchResult.reasons) ? stitchResult.reasons : [], }); } return sendJSON(res, 200, { success: true, message: '上传并解压成功', folderName: targetFolderName, path: `${targetType}/${targetFolderName}`, hint: '如果页面无法预览,让 AI 处理即可' }); } catch (e: any) { console.error('[文件系统 API] 解压失败:', e); if (e?.code === 'ENAMETOOLONG') { return sendJSON(res, 500, { error: '解压失败:ZIP 内部路径过长(超出系统限制)。请解压后上传文件夹,或缩短文件名后重试。', }); } return sendJSON(res, 500, { error: `解压失败: ${e.message}` }); } } // AI 处理类型:v0, google_aistudio, figma_make if (['v0', 'google_aistudio', 'figma_make'].includes(uploadType)) { try { // 解压到 temp 目录 const timestamp = Date.now(); const basename = isFolderUpload ? (folderUploadContext?.fallbackName || folderNameField || derivedRootName || `upload-${timestamp}`) : path.basename(originalFilename, path.extname(originalFilename)); const extractDirName = `${uploadType}-${truncateName(sanitizeFolderName(basename), 40)}-${timestamp}`; const extractDir = isFolderUpload ? (folderUploadContext!.inferred.hasRootFolder ? path.join(folderUploadContext!.tempExtractDir, folderUploadContext!.inferred.rootFolderName) : folderUploadContext!.tempExtractDir) : path.join(projectRoot, 'temp', extractDirName); if (!isFolderUpload) { fs.mkdirSync(extractDir, { recursive: true }); await extractZip(tempFilePath, { dir: extractDir }); fs.unlinkSync(tempFilePath); } const pageName = basename .replace(/[^a-z0-9-]/gi, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); const isThemeTarget = targetType === 'themes'; const converterConfigs: Record = { v0: { label: 'V0', scriptFile: 'v0-converter.mjs', tasksFileName: '.v0-tasks.md', themeTasksFileName: '.v0-theme-tasks.md', }, google_aistudio: { label: 'AI Studio', scriptFile: 'ai-studio-converter.mjs', tasksFileName: '.ai-studio-tasks.md', themeTasksFileName: '.ai-studio-theme-tasks.md', }, figma_make: { label: 'Figma Make', scriptFile: 'figma-make-converter.mjs', tasksFileName: '.figma-make-tasks.md', themeTasksFileName: '.figma-make-theme-tasks.md', }, }; const converterConfig = converterConfigs[String(uploadType)]; if (!converterConfig) { throw new Error(`未知的上传类型: ${uploadType}`); } const scriptPath = path.join(projectRoot, 'scripts', converterConfig.scriptFile); const tasksFileName = isThemeTarget ? converterConfig.themeTasksFileName : converterConfig.tasksFileName; const commandArgs = [scriptPath, extractDir, pageName, '--target-type', String(targetType)]; console.log(`[${converterConfig.label} 转换] 执行预处理脚本:`, `node ${commandArgs.join(' ')}`); try { const commandResult = runCommandSync({ command: nodeCommand, args: commandArgs, cwd: projectRoot, }); if (commandResult.status !== 0) { throw new Error(commandResult.stderr || commandResult.stdout || `exit=${commandResult.status}`); } const output = commandResult.stdout; console.log(`[${converterConfig.label} 转换] 执行成功:`, output); const tasksFilePath = path.join(projectRoot, 'src', targetType, pageName, tasksFileName); if (!fs.existsSync(tasksFilePath)) { throw new Error(`任务文档生成失败: ${tasksFileName}`); } const tasksFileRelPath = `src/${targetType}/${pageName}/${tasksFileName}`; const prompt = isThemeTarget ? `${converterConfig.label} 项目已上传并预处理完成(主题模式)。\n\n请仔细阅读并执行主题任务清单:\n- ${tasksFileRelPath}\n\n然后请基于该任务清单和技能文档,进行主题拆分(输出到 \`src/themes/${pageName}/\`、\`src/docs/\`、\`src/database/\`)。` : `${converterConfig.label} 项目已上传并预处理完成。\n\n请仔细阅读并执行以下任务清单:\n- ${tasksFileRelPath}\n\n然后根据该任务清单和技能文档,完成具体的转换工作。`; return sendJSON(res, 200, { success: true, uploadType, pageName, tasksFile: tasksFileRelPath, prompt, message: isThemeTarget ? '主题预处理完成,请查看任务文档' : '预处理完成,请查看任务文档' }); } catch (scriptError: any) { console.error(`[${converterConfig.label} 转换] 执行失败:`, scriptError); const pageDir = path.join(projectRoot, 'src', targetType, pageName); if (fs.existsSync(pageDir)) { fs.rmSync(pageDir, { recursive: true, force: true }); } return sendJSON(res, 500, { error: `预处理脚本执行失败: ${scriptError.message}`, details: scriptError.stderr || scriptError.stdout || scriptError.message }); } } catch (e: any) { console.error('[文件系统 API] 解压失败:', e); if (e?.code === 'ENAMETOOLONG') { return sendJSON(res, 500, { error: '解压失败:ZIP 内部路径过长(超出系统限制)。请解压后上传文件夹,或缩短文件名后重试。', }); } return sendJSON(res, 500, { error: `解压失败: ${e.message}` }); } } // 未知类型 return sendJSON(res, 400, { error: `不支持的上传类型: ${uploadType}` }); } catch (e: any) { console.error('[文件系统 API] 文件处理失败:', e); return sendJSON(res, 500, { error: e.message || 'File processing failed' }); } }); } catch (e: any) { console.error('[文件系统 API] 上传失败:', e); sendJSON(res, 500, { error: e.message || 'Upload failed' }); } }); // ==================== /api/upload-screenshots ==================== server.middlewares.use('/api/upload-screenshots', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const form = formidable({ uploadDir: path.join(projectRoot, 'temp'), keepExtensions: true, maxFileSize: 20 * 1024 * 1024, // 20MB per image multiples: true, }); form.parse(req, async (err: any, fields: any, files: any) => { if (err) { console.error('[文件系统 API] 截图上传解析失败:', err); return sendJSON(res, 500, { error: 'Upload parsing failed' }); } try { const getFieldValue = (field: any) => Array.isArray(field) ? field[0] : field; const rawBatchId = getFieldValue(fields.batchId); const targetType = getFieldValue(fields.targetType); const batchId = (typeof rawBatchId === 'string' ? rawBatchId : '') .trim() .replace(/[^a-z0-9_-]/gi, '') .slice(0, 64); if ( targetType && !SUPPORTED_UPLOAD_TARGET_TYPES.includes(String(targetType) as (typeof SUPPORTED_UPLOAD_TARGET_TYPES)[number]) ) { return sendJSON(res, 400, { error: `Invalid targetType: ${targetType}. Supported targetType: ${SUPPORTED_UPLOAD_TARGET_TYPES.join(', ')}` }); } const isThemeTarget = targetType === 'themes'; const resolvedBatchId = batchId || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const screenshotsDir = path.join(projectRoot, 'temp', 'screenshots', resolvedBatchId); fs.mkdirSync(screenshotsDir, { recursive: true }); const fileInput = (files.file ?? files.files) as any; const fileList = Array.isArray(fileInput) ? fileInput : (fileInput ? [fileInput] : []); if (fileList.length === 0) { return sendJSON(res, 400, { error: 'Missing file' }); } const savedNames: string[] = []; for (const file of fileList) { const tempFilePath = file.filepath || file.path || file.tempFilePath; const originalFilename = file.originalFilename || file.name || file.filename || 'screenshot'; if (!tempFilePath || !fs.existsSync(tempFilePath)) { continue; } let safeName = path.basename(originalFilename).trim(); safeName = safeName.replace(/[^\w.\- ]+/g, '-').replace(/\s+/g, '-'); if (!safeName) safeName = 'screenshot'; const ext = path.extname(safeName) || path.extname(originalFilename) || path.extname(tempFilePath) || ''; const base = ext ? safeName.slice(0, -ext.length) : safeName; let candidate = `${base}${ext}`; let counter = 2; while (fs.existsSync(path.join(screenshotsDir, candidate))) { candidate = `${base}-${counter}${ext}`; counter += 1; } const destPath = path.join(screenshotsDir, candidate); moveFileWithFallback(tempFilePath, destPath); savedNames.push(candidate); } const entries = fs.readdirSync(screenshotsDir, { withFileTypes: true }); const filePaths = entries .filter(entry => entry.isFile()) .map(entry => normalizePath(path.join('temp', 'screenshots', resolvedBatchId, entry.name))) .sort((a, b) => a.localeCompare(b)); const docs = isThemeTarget ? [ '/skills/screen-to-code/SKILL.md', '/skills/screen-to-code/screenshot-collection.md', ...THEME_IMPORT_SUB_SKILL_DOCS, ] : [ '/skills/screen-to-code/SKILL.md', '/skills/screen-to-code/screenshot-collection.md', ]; const prompt = isThemeTarget ? `**系统指令**:你将作为UI/UX 设计架构师 × 前端工程师(复合型),协助用户「基于截图导入并创建主题」。 请严格按以下技能文档执行: ${formatReferenceList(docs)} 截图清单(已上传到工作区): ${filePaths.map(p => `- \`${p}\``).join('\n')} 先和用户确认 \`theme-key\` 与输出范围(是否需要文档/数据),然后基于截图生成主题 token、设计规范文档与主题示例入口,必要时补充 \`src/docs/\` 与 \`src/database/\`。` : `**系统指令**:你将作为UI/UX 设计架构师 × 前端工程师(复合型),协助用户「基于截图导入并创建页面/元素」。 请严格按以下技能文档执行(必须完整跑完 Phase 0 → 5): ${formatReferenceList(docs)} 截图清单(已上传到工作区): ${filePaths.map(p => `- \`${p}\``).join('\n')} 从 Phase 0 开始:先确认要生成页面还是元素、目标 name(kebab-case)、是否允许优化设计/交互;然后按文档产出抽象 JSON → 代码蓝图 → 再生成代码。`; return sendJSON(res, 200, { success: true, batchId: resolvedBatchId, files: filePaths, saved: savedNames, prompt, message: filePaths.length > 1 ? `已上传 ${filePaths.length} 张截图` : '已上传 1 张截图', }); } catch (e: any) { console.error('[文件系统 API] 截图处理失败:', e); return sendJSON(res, 500, { error: e.message || 'File processing failed' }); } }); } catch (e: any) { console.error('[文件系统 API] 截图上传失败:', e); return sendJSON(res, 500, { error: e.message || 'Upload failed' }); } }); // ==================== /api/export-make ==================== server.middlewares.use('/api/export-make', async (req: any, res: any) => { if (req.method !== 'GET') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const url = new URL(req.url, `http://${req.headers.host}`); const targetPath = (url.searchParams.get('path') || '').trim(); if (!targetPath) { return sendJSON(res, 400, { error: 'Missing path parameter' }); } if (!isSafeSrcTargetPath(targetPath)) { return sendJSON(res, 403, { error: 'Invalid path' }); } const itemDir = path.join(projectRoot, 'src', targetPath); if (!fs.existsSync(itemDir) || !fs.statSync(itemDir).isDirectory()) { return sendJSON(res, 404, { error: 'Directory not found' }); } const probe = url.searchParams.get('probe') === '1'; const promptMode = url.searchParams.get('prompt') === '1'; const snapshot = analyzeMakeAssets(itemDir, targetPath); if (probe) { return sendJSON(res, 200, { ok: true, path: targetPath, hasMakeAssets: snapshot.hasMakeAssets, lastExportedAt: snapshot.lastExportedAt, fileName: snapshot.fileName, hasCanvasFig: snapshot.hasCanvasFig, hasMetaJson: snapshot.hasMetaJson, hasAiChat: snapshot.hasAiChat, hasThumbnail: snapshot.hasThumbnail, hasManifest: snapshot.hasManifest, hasImagesDir: snapshot.hasImagesDir, imageCount: snapshot.imageCount, hasDriftRisk: snapshot.hasDriftRisk, driftReasons: snapshot.driftReasons, }); } if (promptMode) { return sendJSON(res, 200, { ok: true, path: targetPath, hasMakeAssets: snapshot.hasMakeAssets, fileName: snapshot.fileName, hasDriftRisk: snapshot.hasDriftRisk, driftReasons: snapshot.driftReasons, prompt: buildMakeExportPrompt(targetPath), }); } if (!snapshot.hasCanvasFig) { return sendJSON(res, 409, { error: '当前页面尚未生成 .fig 导出所需资产,请先复制 Prompt 让 AI 补齐。', hasMakeAssets: false, fileName: snapshot.fileName, hasDriftRisk: snapshot.hasDriftRisk, driftReasons: snapshot.driftReasons, prompt: buildMakeExportPrompt(targetPath), }); } if (snapshot.hasDriftRisk) { return sendJSON(res, 409, { error: '检测到当前页面与 Figma 导出壳子可能未同步,请先按 Prompt 同步后再导出 .fig。', hasMakeAssets: true, fileName: snapshot.fileName, hasDriftRisk: true, driftReasons: snapshot.driftReasons, prompt: buildMakeExportPrompt(targetPath), }); } const scriptPath = path.join(projectRoot, 'scripts', 'canvas-fig-sync.mjs'); const packResult = runCommandSync({ command: nodeCommand, args: [ scriptPath, 'pack', '--fig', snapshot.canvasFigPath, '--from', itemDir, '--prune-missing', '--sanitize-for-export', '--manifest', snapshot.manifestPath, ], cwd: projectRoot, }); if (packResult.status !== 0) { throw new Error(packResult.stderr || packResult.stdout || `pack failed: exit=${packResult.status}`); } const inspectResult = runCommandSync({ command: nodeCommand, args: [ scriptPath, 'inspect', '--fig', snapshot.canvasFigPath, '--manifest', snapshot.manifestPath, ], cwd: projectRoot, }); if (inspectResult.status !== 0) { throw new Error(inspectResult.stderr || inspectResult.stdout || `inspect failed: exit=${inspectResult.status}`); } const meta = ensureMakeMeta(itemDir, targetPath); ensureMakeAiChat(itemDir); const fileNameBase = typeof meta?.file_name === 'string' && meta.file_name.trim() ? meta.file_name.trim() : path.basename(targetPath); const downloadFileName = fileNameBase.endsWith('.fig') ? fileNameBase : `${fileNameBase}.fig`; res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', buildAttachmentContentDisposition(downloadFileName)); try { const stream = fs.createReadStream(snapshot.canvasFigPath); stream.on('error', (streamError: any) => { console.error('[文件系统 API] export-make fig 读取失败:', streamError); if (!res.headersSent) { sendJSON(res, 500, { error: `读取 .fig 失败: ${streamError.message}` }); } else { res.end(); } }); await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('error', reject); res.on('close', resolve); stream.pipe(res); }); } catch (streamError: any) { console.error('[文件系统 API] export-make fig 输出失败:', streamError); if (!res.headersSent) { return sendJSON(res, 500, { error: `输出 .fig 失败: ${streamError.message}` }); } } } catch (e: any) { console.error('[文件系统 API] export-make 失败:', e); if (!res.headersSent) { sendJSON(res, 500, { error: e.message || 'Export make failed' }); } } }); // ==================== /api/zip ==================== server.middlewares.use('/api/zip', async (req: any, res: any) => { if (req.method !== 'GET') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const url = new URL(req.url, `http://${req.headers.host}`); const targetPath = url.searchParams.get('path'); // e.g., 'prototypes/antd-demo' if (!targetPath) { return sendJSON(res, 400, { error: 'Missing path parameter' }); } // 验证路径安全性 if (targetPath.includes('..') || targetPath.startsWith('/')) { return sendJSON(res, 403, { error: 'Invalid path' }); } const srcDir = path.join(projectRoot, 'src', targetPath); if (!fs.existsSync(srcDir)) { return sendJSON(res, 404, { error: 'Directory not found' }); } const probe = url.searchParams.get('probe') === '1'; const fileName = `${path.basename(targetPath)}.zip`; if (probe) { return sendJSON(res, 200, { ok: true, fileName, path: targetPath, }); } res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', buildAttachmentContentDisposition(fileName)); // 使用 streaming 方式创建 ZIP(避免在内存中构建整个 zip buffer) try { const archive = new (archiver as any)('zip', { zlib: { level: 9 } }); archive.on('warning', (warning: any) => { console.warn('[文件系统 API] ZIP warning:', warning); }); archive.on('error', (zipError: any) => { console.error('[文件系统 API] ZIP 创建失败:', zipError); if (!res.headersSent) { sendJSON(res, 500, { error: `创建 ZIP 失败: ${zipError.message}` }); } else { res.end(); } }); archive.pipe(res); archive.directory(srcDir, false); await new Promise((resolve) => { res.on('close', resolve); res.on('finish', resolve); archive.on('error', resolve); archive.finalize(); }); } catch (zipError: any) { console.error('[文件系统 API] ZIP 创建失败:', zipError); if (!res.headersSent) { return sendJSON(res, 500, { error: `创建 ZIP 失败: ${zipError.message}` }); } } } catch (e: any) { console.error('[文件系统 API] zip 失败:', e); if (!res.headersSent) { sendJSON(res, 500, { error: e.message || 'Zip failed' }); } } }); // ==================== /api/copy ==================== server.middlewares.use('/api/copy', async (req: any, res: any) => { if (req.method !== 'POST') { return sendJSON(res, 405, { error: 'Method not allowed' }); } try { const { sourcePath, targetPath } = await parseBody(req); if (!sourcePath || !targetPath) { return sendJSON(res, 400, { error: 'Missing sourcePath or targetPath parameter' }); } // 验证路径安全性 if (sourcePath.includes('..') || targetPath.includes('..')) { return sendJSON(res, 403, { error: 'Invalid path' }); } // 验证目标路径不包含中文字符 const targetFolderName = path.basename(targetPath); if (/[\u4e00-\u9fa5]/.test(targetFolderName)) { return sendJSON(res, 400, { error: 'Target folder name cannot contain Chinese characters' }); } // sourcePath 和 targetPath 格式: src/components/xxx 或 src/prototypes/xxx const sourceDir = path.join(projectRoot, sourcePath); const targetDir = path.join(projectRoot, targetPath); if (!fs.existsSync(sourceDir)) { return sendJSON(res, 404, { error: 'Source directory not found' }); } if (fs.existsSync(targetDir)) { return sendJSON(res, 409, { error: 'Target directory already exists' }); } // 复制目录 copyDir(sourceDir, targetDir); // 更新副本的 @name 注释 const indexFiles = ['index.tsx', 'index.ts', 'index.jsx', 'index.js']; let indexFilePath: string | null = null; for (const fileName of indexFiles) { const filePath = path.join(targetDir, fileName); if (fs.existsSync(filePath)) { indexFilePath = filePath; break; } } if (indexFilePath) { try { let content = fs.readFileSync(indexFilePath, 'utf8'); // 提取文件夹名中的副本编号 const copyMatch = targetFolderName.match(/-copy(\d*)$/); let copySuffix = '副本'; if (copyMatch) { const copyNum = copyMatch[1]; copySuffix = copyNum ? `副本${copyNum}` : '副本'; } // 更新 @name 注释 content = content.replace( /(@name\s+)([^\n]+)/, (match, prefix, name) => { // 如果名称已经包含"副本",先移除 const cleanName = name.replace(/\s*副本\d*\s*$/, '').trim(); return `${prefix}${cleanName} ${copySuffix}`; } ); fs.writeFileSync(indexFilePath, content, 'utf8'); } catch (e) { console.error('[文件系统 API] 更新 @name 注释失败:', e); // 不影响主流程,继续执行 } } sendJSON(res, 200, { success: true }); } catch (e: any) { console.error('[文件系统 API] 复制失败:', e); sendJSON(res, 500, { error: e.message || 'Copy failed' }); } }); } }; }