history.js 10 KB

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