history.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. 'use strict';
  2. // 历史创作路由:门店/生成物扫描、创作历史图片墙、workspace 图片文件服务
  3. const fs = require('fs');
  4. const path = require('path');
  5. const { sendJson, IMAGE_EXT, MIME_BY_EXT, SKIP_DIR_NAMES } = require('../util.js');
  6. const { getImageSize } = require('../../imageMeta.js');
  7. // ---------- scan helpers ----------
  8. // 尺寸缓存:按 mtime+size 命中即免读整个文件。/api/scan 每次要列出全部门店的
  9. // 全部菜品图尺寸,无缓存时每次扫描都逐张 readFileSync,是生成页打开慢的主因。
  10. const dimCache = new Map(); // path -> { mtimeMs, size, width, height }
  11. function fileInfo(p) {
  12. const stat = fs.statSync(p);
  13. const hit = dimCache.get(p);
  14. if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
  15. return {
  16. url: '/files?path=' + encodeURIComponent(p),
  17. width: hit.width,
  18. height: hit.height,
  19. fileSizeBytes: stat.size,
  20. };
  21. }
  22. const buf = fs.readFileSync(p);
  23. const dims = getImageSize(buf) || {};
  24. dimCache.set(p, { mtimeMs: stat.mtimeMs, size: stat.size, width: dims.width || null, height: dims.height || null });
  25. return {
  26. url: '/files?path=' + encodeURIComponent(p),
  27. width: dims.width || null,
  28. height: dims.height || null,
  29. fileSizeBytes: buf.length,
  30. };
  31. }
  32. function scanGenerated(shopDir) {
  33. const dir = path.join(shopDir, '_生成图片');
  34. const result = { logo: null, banner: null, signage: null, sticker: null, dishes: {} };
  35. if (!fs.existsSync(dir)) return result;
  36. for (const [kind, base] of [['logo', 'logo'], ['banner', 'banner'], ['signage', '招牌头图'], ['sticker', '贴纸']]) {
  37. for (const ext of IMAGE_EXT) {
  38. const p = path.join(dir, base + ext);
  39. if (fs.existsSync(p)) {
  40. try {
  41. result[kind] = fileInfo(p);
  42. } catch {
  43. // ignore unreadable file
  44. }
  45. break;
  46. }
  47. }
  48. }
  49. const dishDir = path.join(dir, '菜品图');
  50. if (fs.existsSync(dishDir)) {
  51. for (const f of fs.readdirSync(dishDir)) {
  52. const ext = path.extname(f).toLowerCase();
  53. if (!IMAGE_EXT.has(ext)) continue;
  54. const name = f.slice(0, -ext.length);
  55. try {
  56. result.dishes[name] = fileInfo(path.join(dishDir, f));
  57. } catch {
  58. // ignore unreadable file
  59. }
  60. }
  61. }
  62. return result;
  63. }
  64. function handleScan(ctx, query, res) {
  65. const root = query.root;
  66. if (!ctx.workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' });
  67. if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
  68. return sendJson(res, 400, { error: '目录不存在: ' + root });
  69. }
  70. let selectedFolders = null;
  71. if (query.folders) {
  72. try {
  73. selectedFolders = JSON.parse(query.folders);
  74. } catch {
  75. return sendJson(res, 400, { error: '指定店铺参数无效' });
  76. }
  77. if (!Array.isArray(selectedFolders) || !selectedFolders.length ||
  78. selectedFolders.some((name) => typeof name !== 'string' || !name ||
  79. name === '.' || name === '..' || name.includes('/') || name.includes('\\'))) {
  80. return sendJson(res, 400, { error: '指定店铺参数无效' });
  81. }
  82. selectedFolders = [...new Set(selectedFolders)];
  83. }
  84. let entries;
  85. try {
  86. entries = fs.readdirSync(root, { withFileTypes: true });
  87. } catch (e) {
  88. return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
  89. }
  90. const shops = [];
  91. for (const ent of entries) {
  92. if (!ent.isDirectory()) continue;
  93. if (ent.name.startsWith('.') || ent.name === '图片生成工具') continue;
  94. if (selectedFolders && !selectedFolders.includes(ent.name)) continue;
  95. const folderName = ent.name;
  96. const shopDir = path.join(root, folderName);
  97. let files;
  98. try {
  99. files = fs.readdirSync(shopDir, { withFileTypes: true });
  100. } catch {
  101. continue;
  102. }
  103. const dishes = [];
  104. for (const f of files) {
  105. if (!f.isFile()) continue;
  106. const ext = path.extname(f.name).toLowerCase();
  107. if (!IMAGE_EXT.has(ext)) continue;
  108. dishes.push({
  109. file: f.name,
  110. name: f.name.slice(0, -ext.length).replace(/^\d+_/, ''),
  111. url: '/files?path=' + encodeURIComponent(path.join(shopDir, f.name)),
  112. });
  113. }
  114. if (!dishes.length) continue;
  115. dishes.sort((a, b) => a.file.localeCompare(b.file, 'zh'));
  116. let overrides = {};
  117. const cfgPath = path.join(shopDir, '_生成图片', 'prompt-config.json');
  118. if (fs.existsSync(cfgPath)) {
  119. try {
  120. overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  121. } catch {
  122. // ignore malformed override file
  123. }
  124. }
  125. const parsedName = folderName.replace(/^\d+_/, '').replace(/_\d+$/, '');
  126. shops.push({
  127. folderName,
  128. shopName: overrides.shopName || parsedName,
  129. dishes,
  130. promptOverrides: overrides.prompts || {},
  131. generated: scanGenerated(shopDir),
  132. });
  133. }
  134. shops.sort((a, b) => a.folderName.localeCompare(b.folderName, 'zh'));
  135. sendJson(res, 200, { root, shops });
  136. }
  137. // ---------- 历史创作 ----------
  138. const CREATION_KINDS = new Set(['logo', 'banner', 'signage', 'sticker', 'dish']);
  139. function getCreationKind(directoryName, fileName) {
  140. if (directoryName === '菜品图') return 'dish';
  141. if (/^logo\./i.test(fileName)) return 'logo';
  142. if (/^(banner|店内海报)/i.test(fileName)) return 'banner';
  143. if (/^招牌头图/i.test(fileName)) return 'signage';
  144. if (/^贴纸/i.test(fileName)) return 'sticker';
  145. return 'other';
  146. }
  147. function handleCreations(ctx, query, res) {
  148. const root = query.root;
  149. if (!ctx.workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  150. const resolvedRoot = path.resolve(String(root));
  151. if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
  152. return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot });
  153. }
  154. const limit = Math.min(Math.max(Number(query.limit) || 300, 1), 600);
  155. const retentionCutoff = Date.now() - 48 * 60 * 60 * 1000;
  156. const items = [];
  157. let entries;
  158. try {
  159. entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
  160. } catch (e) {
  161. return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
  162. }
  163. for (const entry of entries) {
  164. if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '图片生成工具' || SKIP_DIR_NAMES.has(entry.name)) continue;
  165. const shopDir = path.join(resolvedRoot, entry.name);
  166. const outputDir = path.join(shopDir, '_生成图片');
  167. if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) continue;
  168. let shopName = entry.name.replace(/^\d+_/, '').replace(/_\d+$/, '');
  169. const cfgPath = path.join(outputDir, 'prompt-config.json');
  170. if (fs.existsSync(cfgPath)) {
  171. try {
  172. const overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  173. shopName = overrides.shopName || shopName;
  174. } catch {}
  175. }
  176. const nestedHistoryRecords = ctx.generation.loadCreationHistory(path.join(outputDir, '菜品图'))
  177. .map((record) => ({
  178. ...record,
  179. relativePath: path.normalize(path.join('菜品图', record.relativePath || record.fileName || '.')),
  180. }));
  181. const historyRecords = [...ctx.generation.loadCreationHistory(outputDir), ...nestedHistoryRecords];
  182. const seenHistoryIds = new Set();
  183. const historyPaths = new Set();
  184. for (const record of historyRecords) {
  185. if (record.id && seenHistoryIds.has(record.id)) continue;
  186. if (record.id) seenHistoryIds.add(record.id);
  187. if (!record.remoteUrl || !record.fileName) continue;
  188. const generatedAt = record.generatedAt || record.modifiedAt;
  189. const generatedTimeMs = generatedAt ? new Date(generatedAt).getTime() : 0;
  190. if (!Number.isFinite(generatedTimeMs) || generatedTimeMs < retentionCutoff) continue;
  191. const kind = CREATION_KINDS.has(record.kind) ? record.kind : getCreationKind(record.relativePath || '.', record.fileName);
  192. if (!kind || kind === 'other') continue;
  193. const relativePath = path.normalize(record.relativePath || record.fileName);
  194. historyPaths.add(relativePath);
  195. items.push({
  196. folderName: entry.name,
  197. shopName: record.shopName || shopName,
  198. kind,
  199. name: record.name || record.dishName || path.basename(record.fileName, path.extname(record.fileName)),
  200. fileName: record.fileName,
  201. url: record.remoteUrl,
  202. remoteUrl: record.remoteUrl,
  203. localPath: path.join(outputDir, relativePath),
  204. source: 'remote',
  205. model: record.model,
  206. cost: record.cost,
  207. modifiedAt: generatedAt,
  208. modifiedTimeMs: generatedTimeMs,
  209. width: record.width,
  210. height: record.height,
  211. fileSizeBytes: record.fileSizeBytes,
  212. });
  213. }
  214. for (const relativeDir of ['.', '菜品图']) {
  215. const currentDir = path.join(outputDir, relativeDir);
  216. if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) continue;
  217. let files;
  218. try {
  219. files = fs.readdirSync(currentDir, { withFileTypes: true });
  220. } catch {
  221. continue;
  222. }
  223. for (const file of files) {
  224. if (!file.isFile()) continue;
  225. const ext = path.extname(file.name).toLowerCase();
  226. if (!IMAGE_EXT.has(ext)) continue;
  227. const kind = getCreationKind(relativeDir, file.name);
  228. if (kind === 'other') continue;
  229. const fullPath = path.join(currentDir, file.name);
  230. const relativePath = path.relative(outputDir, fullPath);
  231. if (historyPaths.has(path.normalize(relativePath))) continue;
  232. let stat;
  233. try {
  234. stat = fs.statSync(fullPath);
  235. } catch {
  236. continue;
  237. }
  238. if (stat.mtimeMs < retentionCutoff) continue;
  239. items.push({
  240. folderName: entry.name,
  241. shopName,
  242. kind,
  243. name: path.basename(file.name, ext),
  244. fileName: file.name,
  245. url: '/files?path=' + encodeURIComponent(fullPath),
  246. modifiedAt: stat.mtime.toISOString(),
  247. modifiedTimeMs: stat.mtimeMs,
  248. fileSizeBytes: stat.size,
  249. });
  250. }
  251. }
  252. }
  253. items.sort((a, b) => b.modifiedTimeMs - a.modifiedTimeMs);
  254. sendJson(res, 200, { root: resolvedRoot, total: items.length, items: items.slice(0, limit) });
  255. }
  256. // ---------- workspace 图片文件服务 ----------
  257. function handleFiles(ctx, query, res) {
  258. const p = query.path;
  259. if (!p) {
  260. res.writeHead(400);
  261. return res.end('missing path');
  262. }
  263. const resolved = path.resolve(p);
  264. if (!ctx.workspace.isWorkspacePath(resolved)) {
  265. res.writeHead(403);
  266. return res.end('forbidden');
  267. }
  268. if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
  269. res.writeHead(404);
  270. return res.end('not found');
  271. }
  272. const ext = path.extname(resolved).toLowerCase();
  273. if (!IMAGE_EXT.has(ext)) {
  274. res.writeHead(403);
  275. return res.end('forbidden');
  276. }
  277. res.writeHead(200, { 'Content-Type': MIME_BY_EXT[ext], 'Cache-Control': 'no-cache' });
  278. fs.createReadStream(resolved).pipe(res);
  279. }
  280. function register(app, ctx) {
  281. app.get('/api/scan', async ({ res, query }) => handleScan(ctx, query, res));
  282. app.get('/api/creations', async ({ res, query }) => handleCreations(ctx, query, res));
  283. app.get('/files', async ({ res, query }) => handleFiles(ctx, query, res));
  284. }
  285. module.exports = { register };