| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- '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 ----------
- // 尺寸缓存:按 mtime+size 命中即免读整个文件。/api/scan 每次要列出全部门店的
- // 全部菜品图尺寸,无缓存时每次扫描都逐张 readFileSync,是生成页打开慢的主因。
- const dimCache = new Map(); // path -> { mtimeMs, size, width, height }
- function fileInfo(p) {
- const stat = fs.statSync(p);
- const hit = dimCache.get(p);
- if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
- return {
- url: '/files?path=' + encodeURIComponent(p),
- width: hit.width,
- height: hit.height,
- fileSizeBytes: stat.size,
- };
- }
- const buf = fs.readFileSync(p);
- const dims = getImageSize(buf) || {};
- dimCache.set(p, { mtimeMs: stat.mtimeMs, size: stat.size, width: dims.width || null, height: dims.height || null });
- 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 };
|