Files
ONE-OS/axhub-make/vite-plugins/fileSystemApiPlugin.ts
王冕 a27e3b8e43 feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules,
and temp folders to ensure clean and lightweight version tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:12:25 +08:00

2358 lines
100 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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<string>): 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<string>,
): { 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<string>();
const seenItemKeys = new Set<string>();
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<string>,
): SidebarTreeNode[] => {
const usedIds = new Set<string>();
const seenItemKeys = new Set<string>();
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<string> => {
const ids = new Set<string>();
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<string>) => {
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<string>();
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<string> => {
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<string> => {
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<string> => {
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<string> => {
const themesDir = path.join(projectRoot, 'src', 'themes');
const keys = new Set<string>();
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<string> => {
const databaseDir = path.join(projectRoot, 'src', 'database');
const keys = new Set<string>();
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<string> => {
if (type === 'themes') {
return collectThemeKeys();
}
if (type === 'data') {
return collectDataTableKeys();
}
const templatesDir = getTemplatesDir(projectRoot);
const keys = new Set<string>();
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>): string[] => {
const seen = new Set<string>();
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<any> => {
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<string>();
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<string>();
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/<theme-key>/\`\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<string, {
label: string;
scriptFile: string;
tasksFileName: string;
themeTasksFileName: string;
}> = {
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 开始:先确认要生成页面还是元素、目标 namekebab-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<void>((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<void>((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' });
}
});
}
};
}