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>
2358 lines
100 KiB
TypeScript
2358 lines
100 KiB
TypeScript
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 开始:先确认要生成页面还是元素、目标 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<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' });
|
||
}
|
||
});
|
||
}
|
||
};
|
||
}
|