'use strict'; // 历史创作路由:门店/生成物扫描、创作历史图片墙、workspace 图片文件服务 const fs = require('fs'); const path = require('path'); const { sendJson, IMAGE_EXT, MIME_BY_EXT, SKIP_DIR_NAMES } = require('../util.js'); const { getImageSize } = require('../../imageMeta.js'); // ---------- scan helpers ---------- function fileInfo(p) { const buf = fs.readFileSync(p); const dims = getImageSize(buf) || {}; return { url: '/files?path=' + encodeURIComponent(p), width: dims.width || null, height: dims.height || null, fileSizeBytes: buf.length, }; } function scanGenerated(shopDir) { const dir = path.join(shopDir, '_生成图片'); const result = { logo: null, banner: null, signage: null, sticker: null, dishes: {} }; if (!fs.existsSync(dir)) return result; for (const [kind, base] of [['logo', 'logo'], ['banner', 'banner'], ['signage', '招牌头图'], ['sticker', '贴纸']]) { for (const ext of IMAGE_EXT) { const p = path.join(dir, base + ext); if (fs.existsSync(p)) { try { result[kind] = fileInfo(p); } catch { // ignore unreadable file } break; } } } const dishDir = path.join(dir, '菜品图'); if (fs.existsSync(dishDir)) { for (const f of fs.readdirSync(dishDir)) { const ext = path.extname(f).toLowerCase(); if (!IMAGE_EXT.has(ext)) continue; const name = f.slice(0, -ext.length); try { result.dishes[name] = fileInfo(path.join(dishDir, f)); } catch { // ignore unreadable file } } } return result; } function handleScan(ctx, query, res) { const root = query.root; if (!ctx.workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' }); if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) { return sendJson(res, 400, { error: '目录不存在: ' + root }); } let selectedFolders = null; if (query.folders) { try { selectedFolders = JSON.parse(query.folders); } catch { return sendJson(res, 400, { error: '指定店铺参数无效' }); } if (!Array.isArray(selectedFolders) || !selectedFolders.length || selectedFolders.some((name) => typeof name !== 'string' || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\'))) { return sendJson(res, 400, { error: '指定店铺参数无效' }); } selectedFolders = [...new Set(selectedFolders)]; } let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { return sendJson(res, 400, { error: '无法读取目录: ' + e.message }); } const shops = []; for (const ent of entries) { if (!ent.isDirectory()) continue; if (ent.name.startsWith('.') || ent.name === '图片生成工具') continue; if (selectedFolders && !selectedFolders.includes(ent.name)) continue; const folderName = ent.name; const shopDir = path.join(root, folderName); let files; try { files = fs.readdirSync(shopDir, { withFileTypes: true }); } catch { continue; } const dishes = []; for (const f of files) { if (!f.isFile()) continue; const ext = path.extname(f.name).toLowerCase(); if (!IMAGE_EXT.has(ext)) continue; dishes.push({ file: f.name, name: f.name.slice(0, -ext.length).replace(/^\d+_/, ''), url: '/files?path=' + encodeURIComponent(path.join(shopDir, f.name)), }); } if (!dishes.length) continue; dishes.sort((a, b) => a.file.localeCompare(b.file, 'zh')); let overrides = {}; const cfgPath = path.join(shopDir, '_生成图片', 'prompt-config.json'); if (fs.existsSync(cfgPath)) { try { overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { // ignore malformed override file } } const parsedName = folderName.replace(/^\d+_/, '').replace(/_\d+$/, ''); shops.push({ folderName, shopName: overrides.shopName || parsedName, dishes, promptOverrides: overrides.prompts || {}, generated: scanGenerated(shopDir), }); } shops.sort((a, b) => a.folderName.localeCompare(b.folderName, 'zh')); sendJson(res, 200, { root, shops }); } // ---------- 历史创作 ---------- const CREATION_KINDS = new Set(['logo', 'banner', 'signage', 'sticker', 'dish']); function getCreationKind(directoryName, fileName) { if (directoryName === '菜品图') return 'dish'; if (/^logo\./i.test(fileName)) return 'logo'; if (/^(banner|店内海报)/i.test(fileName)) return 'banner'; if (/^招牌头图/i.test(fileName)) return 'signage'; if (/^贴纸/i.test(fileName)) return 'sticker'; return 'other'; } function handleCreations(ctx, query, res) { const root = query.root; if (!ctx.workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' }); const resolvedRoot = path.resolve(String(root)); if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot }); } const limit = Math.min(Math.max(Number(query.limit) || 300, 1), 600); const retentionCutoff = Date.now() - 48 * 60 * 60 * 1000; const items = []; let entries; try { entries = fs.readdirSync(resolvedRoot, { withFileTypes: true }); } catch (e) { return sendJson(res, 400, { error: '无法读取目录: ' + e.message }); } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '图片生成工具' || SKIP_DIR_NAMES.has(entry.name)) continue; const shopDir = path.join(resolvedRoot, entry.name); const outputDir = path.join(shopDir, '_生成图片'); if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) continue; let shopName = entry.name.replace(/^\d+_/, '').replace(/_\d+$/, ''); const cfgPath = path.join(outputDir, 'prompt-config.json'); if (fs.existsSync(cfgPath)) { try { const overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); shopName = overrides.shopName || shopName; } catch {} } const nestedHistoryRecords = ctx.generation.loadCreationHistory(path.join(outputDir, '菜品图')) .map((record) => ({ ...record, relativePath: path.normalize(path.join('菜品图', record.relativePath || record.fileName || '.')), })); const historyRecords = [...ctx.generation.loadCreationHistory(outputDir), ...nestedHistoryRecords]; const seenHistoryIds = new Set(); const historyPaths = new Set(); for (const record of historyRecords) { if (record.id && seenHistoryIds.has(record.id)) continue; if (record.id) seenHistoryIds.add(record.id); if (!record.remoteUrl || !record.fileName) continue; const generatedAt = record.generatedAt || record.modifiedAt; const generatedTimeMs = generatedAt ? new Date(generatedAt).getTime() : 0; if (!Number.isFinite(generatedTimeMs) || generatedTimeMs < retentionCutoff) continue; const kind = CREATION_KINDS.has(record.kind) ? record.kind : getCreationKind(record.relativePath || '.', record.fileName); if (!kind || kind === 'other') continue; const relativePath = path.normalize(record.relativePath || record.fileName); historyPaths.add(relativePath); items.push({ folderName: entry.name, shopName: record.shopName || shopName, kind, name: record.name || record.dishName || path.basename(record.fileName, path.extname(record.fileName)), fileName: record.fileName, url: record.remoteUrl, remoteUrl: record.remoteUrl, localPath: path.join(outputDir, relativePath), source: 'remote', model: record.model, cost: record.cost, modifiedAt: generatedAt, modifiedTimeMs: generatedTimeMs, width: record.width, height: record.height, fileSizeBytes: record.fileSizeBytes, }); } for (const relativeDir of ['.', '菜品图']) { const currentDir = path.join(outputDir, relativeDir); if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) continue; let files; try { files = fs.readdirSync(currentDir, { withFileTypes: true }); } catch { continue; } for (const file of files) { if (!file.isFile()) continue; const ext = path.extname(file.name).toLowerCase(); if (!IMAGE_EXT.has(ext)) continue; const kind = getCreationKind(relativeDir, file.name); if (kind === 'other') continue; const fullPath = path.join(currentDir, file.name); const relativePath = path.relative(outputDir, fullPath); if (historyPaths.has(path.normalize(relativePath))) continue; let stat; try { stat = fs.statSync(fullPath); } catch { continue; } if (stat.mtimeMs < retentionCutoff) continue; items.push({ folderName: entry.name, shopName, kind, name: path.basename(file.name, ext), fileName: file.name, url: '/files?path=' + encodeURIComponent(fullPath), modifiedAt: stat.mtime.toISOString(), modifiedTimeMs: stat.mtimeMs, fileSizeBytes: stat.size, }); } } } items.sort((a, b) => b.modifiedTimeMs - a.modifiedTimeMs); sendJson(res, 200, { root: resolvedRoot, total: items.length, items: items.slice(0, limit) }); } // ---------- workspace 图片文件服务 ---------- function handleFiles(ctx, query, res) { const p = query.path; if (!p) { res.writeHead(400); return res.end('missing path'); } const resolved = path.resolve(p); if (!ctx.workspace.isWorkspacePath(resolved)) { res.writeHead(403); return res.end('forbidden'); } if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { res.writeHead(404); return res.end('not found'); } const ext = path.extname(resolved).toLowerCase(); if (!IMAGE_EXT.has(ext)) { res.writeHead(403); return res.end('forbidden'); } res.writeHead(200, { 'Content-Type': MIME_BY_EXT[ext], 'Cache-Control': 'no-cache' }); fs.createReadStream(resolved).pipe(res); } function register(app, ctx) { app.get('/api/scan', async ({ res, query }) => handleScan(ctx, query, res)); app.get('/api/creations', async ({ res, query }) => handleCreations(ctx, query, res)); app.get('/files', async ({ res, query }) => handleFiles(ctx, query, res)); } module.exports = { register };