瀏覽代碼

feat: add task center and job persistence

KenZ1117 5 天之前
父節點
當前提交
4fe9c6ba1b
共有 7 個文件被更改,包括 1569 次插入32 次删除
  1. 15 0
      README.md
  2. 1019 0
      docs/upgrade-roadmap.md
  3. 10 2
      public/app.js
  4. 48 7
      public/index.html
  5. 41 1
      public/style.css
  6. 204 0
      public/tasks.js
  7. 232 22
      server.js

+ 15 - 0
README.md

@@ -102,6 +102,8 @@ workspace/
 
 生成结果的本地文件可能因为重新生成被覆盖;`generation-history.json` 会追加保存远程结果地址,用于历史创作页恢复和下载。
 
+生成任务会保存到输出根目录下的 `.genpic/tasks.json`。页面刷新或服务重启后,任务中心仍能看到任务状态;已经提交给模型服务且仍在运行的任务会恢复轮询。排队任务因原始图片数据不落盘,重启后会标记为失败并提示重新提交。
+
 ## 使用流程
 
 1. 进入 **系统设置**,配置 API Key、默认模型、输出根目录和采集 Cookie。
@@ -131,6 +133,7 @@ workspace/
 │   ├── crawl.js            # 门店采集界面逻辑
 │   ├── gen.js              # 视觉生成界面逻辑
 │   ├── history.js          # 历史创作界面逻辑
+│   ├── tasks.js             # 任务中心界面逻辑
 │   └── style.css           # 统一视觉系统
 ├── config.example.json     # 配置模板
 ├── 启动.command            # macOS 启动脚本
@@ -140,6 +143,17 @@ workspace/
 
 ## 本地数据与安全
 
+服务默认仅监听本机 `127.0.0.1`。如需局域网访问,可临时设置 `HOST=0.0.0.0`,但建议仅在可信网络中使用。
+
+健康检查:
+
+```bash
+curl http://127.0.0.1:5177/healthz
+curl http://127.0.0.1:5177/readyz
+```
+
+本地接口只允许访问当前配置的输出根目录。采集 Cookie 保存后不会在设置页回显;需要更换时重新粘贴完整 Cookie 并保存即可。
+
 以下文件包含本机运行数据或密钥,默认不会被 Git 提交:
 
 ```text
@@ -148,6 +162,7 @@ session.json
 manual-shops.json
 output/
 workspace/
+.genpic/
 ```
 
 请勿将 API Key、后台 Cookie 或门店数据提交到 Git。采集功能只应操作你有权管理的店铺,并遵守平台条款与当地法规。

+ 1019 - 0
docs/upgrade-roadmap.md

@@ -0,0 +1,1019 @@
+# P2 / P3 升级路线
+
+本文档定义「本地单店专业工具」向稳定 SaaS 平台演进的落地路线。原则是:先不推翻现有 Node + 原生前端架构,而是先把任务、资产、配置三条核心数据流稳定下来;等 API 和测试稳定后,再逐步完成 TypeScript 化、CI/CD 和平台能力。
+
+## 总体顺序
+
+```text
+P2-A 任务中心
+P2-B 配置引导
+P2-C 历史资产库
+P2-D 工程质量
+P3-A 模板库
+P3-B 审核流
+P3-C 开放 API
+P3-D 模型市场
+```
+
+推荐按「数据层 → 服务层 → API → UI → 工程化」推进,而不是先大规模重写前端。这样可以保持当前产品可用,同时降低迁移风险。
+
+---
+
+## P2-A 任务中心
+
+### 目标
+
+让生成和采集任务具备持久化、可恢复、可观测、可重试的能力,解决刷新页面、重启服务、批量任务失败后状态不清楚的问题。
+
+### 数据模型
+
+本地版先使用 SQLite;后续 SaaS 可平滑迁移到 PostgreSQL。
+
+```sql
+CREATE TABLE jobs (
+  id TEXT PRIMARY KEY,
+  type TEXT NOT NULL,                 -- crawl | generation
+  status TEXT NOT NULL,               -- queued | submitted | running | succeeded | failed | canceled | timeout
+  workspace_root TEXT NOT NULL,
+  folder_name TEXT,
+  shop_name TEXT,
+  kind TEXT,
+  model TEXT,
+  prompt TEXT,
+  prompt_version TEXT,
+  provider TEXT,
+  provider_task_id TEXT,
+  request_hash TEXT,
+  idempotency_key TEXT UNIQUE,
+  progress TEXT,
+  cost REAL,
+  attempts INTEGER NOT NULL DEFAULT 0,
+  max_attempts INTEGER NOT NULL DEFAULT 3,
+  error_code TEXT,
+  error_message TEXT,
+  warning TEXT,
+  created_at TEXT NOT NULL,
+  started_at TEXT,
+  finished_at TEXT,
+  updated_at TEXT NOT NULL
+);
+
+CREATE TABLE job_events (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  job_id TEXT NOT NULL,
+  event TEXT NOT NULL,
+  level TEXT NOT NULL,
+  message TEXT,
+  payload_json TEXT,
+  created_at TEXT NOT NULL
+);
+```
+
+### 后端改动
+
+1. 新增任务仓库模块:
+   - `packages/jobs/repository.js`
+   - `packages/jobs/queue.js`
+   - `packages/jobs/provider-client.js`
+2. 任务状态机固定为:
+
+   ```text
+   queued → submitted → running → succeeded
+                          ↘ failed / timeout / canceled
+   ```
+
+3. 服务启动时:
+   - 加载未完成任务;
+   - 对有 `provider_task_id` 的任务继续轮询;
+   - 对仍处于 `queued` 的任务重新入队;
+   - 对超过最大重试次数的任务标记失败。
+4. 任务创建时先写数据库,再提交供应商。
+5. 供应商返回任务 ID 后立即更新 `provider_task_id`。
+6. 增加统一接口:
+
+   ```text
+   GET    /api/tasks
+   GET    /api/tasks/:id
+   POST   /api/tasks/:id/retry
+   POST   /api/tasks/:id/cancel
+   DELETE /api/tasks/:id
+   GET    /api/tasks/stream
+   ```
+
+7. `/api/tasks/stream` 使用 SSE 推送:
+   - 任务新增;
+   - 进度更新;
+   - 状态变化;
+   - 日志摘要。
+
+### 前端改动
+
+1. 在侧边栏增加「任务中心」入口。
+2. 顶栏增加全局任务状态图标:
+   - 空闲;
+   - N 个进行中;
+   - 有失败;
+   - 全部完成。
+3. 任务中心支持:
+   - 按类型筛选:全部、采集、生成;
+   - 按状态筛选:排队、进行中、成功、失败;
+   - 查看任务详情;
+   - 重试;
+   - 取消;
+   - 删除记录;
+   - 跳转到结果。
+4. 生成页面保留轻量进度区,但复杂状态统一收敛到任务中心。
+5. 页面刷新后自动恢复未完成任务。
+
+### 验收标准
+
+- 服务重启后,未完成任务仍可见。
+- 页面刷新后,正在生成的任务不丢失。
+- 失败任务能看到错误原因,并能手动重试。
+- 同一个任务的供应商任务 ID 可以追溯。
+- 批量任务不会因为页面关闭而中断。
+
+---
+
+## P2-B 配置引导
+
+### 目标
+
+把「先配置成功,再使用产品」变成可视化流程,减少 API Key、Cookie、输出目录配置错误带来的隐性失败。
+
+### 引导流程
+
+```text
+1. 欢迎
+2. 配置 API Key
+3. 测试生成服务
+4. 配置输出目录
+5. 配置采集 Cookie
+6. 测试采集账号
+7. 选择或扫描门店
+8. 完成首次生成
+```
+
+### 后端接口
+
+新增配置健康接口:
+
+```text
+POST /api/config/test-api
+POST /api/config/test-workspace
+POST /api/config/test-crawl-cookie
+GET  /api/config/status
+POST /api/config/onboarding-complete
+```
+
+`/api/config/status` 返回:
+
+```json
+{
+  "apiConfigured": true,
+  "apiReachable": true,
+  "workspaceReady": true,
+  "workspaceWritable": true,
+  "crawlCookieConfigured": true,
+  "crawlCookieValid": true,
+  "shopsReady": true,
+  "firstGenerationCompleted": false,
+  "onboardingCompleted": false
+}
+```
+
+### 交互设计
+
+1. 首次启动后弹出引导,不在首屏强制打断老用户。
+2. 侧边栏显示 Setup 状态点。
+3. 每个步骤包含:
+   - 当前状态;
+   - 填写入口;
+   - 测试按钮;
+   - 成功提示;
+   - 错误原因;
+   - 下一步。
+4. 失败信息必须给出可操作建议,例如:
+   - API Key 无效;
+   - API 地址不可达;
+   - 目录不可写;
+   - Cookie 缺少 `ksid`;
+   - 采集账号已过期。
+5. 完成后写入本地 onboarding 状态,可在系统设置中重新打开。
+
+### 验收标准
+
+- 新用户能在引导中完成首次可用配置。
+- 每个配置项都有独立的健康状态。
+- 配置错误不会只显示「失败」,而是能定位到具体原因。
+- 用户完成引导后,首页能直接进入门店和生成流程。
+
+---
+
+## P2-C 历史资产库
+
+### 目标
+
+把「历史创作图片墙」升级为可管理的资产库,支持筛选、版本、收藏、删除、恢复和稳定下载。
+
+### 数据模型
+
+```sql
+CREATE TABLE assets (
+  id TEXT PRIMARY KEY,
+  job_id TEXT,
+  workspace_root TEXT NOT NULL,
+  folder_name TEXT NOT NULL,
+  shop_name TEXT NOT NULL,
+  kind TEXT NOT NULL,
+  name TEXT NOT NULL,
+  status TEXT NOT NULL,                -- generated | pending_review | approved | rejected | deleted
+  local_path TEXT,
+  object_key TEXT,
+  remote_url TEXT,
+  thumbnail_path TEXT,
+  file_name TEXT NOT NULL,
+  width INTEGER,
+  height INTEGER,
+  file_size_bytes INTEGER,
+  checksum TEXT,
+  model TEXT,
+  prompt_version TEXT,
+  cost REAL,
+  version INTEGER NOT NULL DEFAULT 1,
+  parent_asset_id TEXT,
+  favorite INTEGER NOT NULL DEFAULT 0,
+  created_at TEXT NOT NULL,
+  updated_at TEXT NOT NULL
+);
+
+CREATE TABLE asset_versions (
+  id TEXT PRIMARY KEY,
+  asset_group_id TEXT NOT NULL,
+  version INTEGER NOT NULL,
+  asset_id TEXT NOT NULL,
+  created_at TEXT NOT NULL
+);
+```
+
+### 后端接口
+
+```text
+GET    /api/assets
+GET    /api/assets/:id
+PATCH  /api/assets/:id
+POST   /api/assets/:id/favorite
+POST   /api/assets/:id/review
+POST   /api/assets/:id/regenerate
+DELETE /api/assets/:id
+GET    /api/assets/:id/download
+GET    /api/assets/:id/thumbnail
+```
+
+支持查询参数:
+
+```text
+keyword
+shopName
+kind
+status
+favorite
+startDate
+endDate
+model
+page
+pageSize
+sort
+```
+
+### 存储策略
+
+本地版:
+
+- 保留本地文件作为主存储;
+- 数据库保存资产索引;
+- 生成缩略图;
+- 计算 checksum;
+- 删除默认进入回收站。
+
+后续 SaaS:
+
+- 主存储迁移到 S3 / OSS / COS;
+- 本地路径仅作为缓存字段;
+- 下载使用短期签名 URL;
+- 历史记录不依赖供应商临时 URL。
+
+### 前端改动
+
+1. 历史创作页升级为资产库。
+2. 增加筛选栏:
+   - 门店;
+   - 类型;
+   - 状态;
+   - 收藏;
+   - 时间;
+   - 模型;
+   - 关键词。
+3. 图片墙继续默认一行 8 个,但大列表使用虚拟滚动。
+4. 卡片悬浮操作:
+   - 放大;
+   - 下载;
+   - 收藏;
+   - 重新生成;
+   - 删除。
+5. 详情抽屉显示:
+   - 大图;
+   - 门店;
+   - 类型;
+   - 尺寸;
+   - 模型;
+   - 提示词版本;
+   - 成本;
+   - 创建时间;
+   - 关联任务。
+6. 增加回收站入口。
+
+### 验收标准
+
+- 重复生成不会覆盖历史索引。
+- 删除后可恢复。
+- 支持按门店、类型、状态、收藏、时间筛选。
+- 大量图片时页面不卡顿。
+- 每个资产都能追溯生成任务和提示词版本。
+
+---
+
+## P2-D 工程质量
+
+### 原则
+
+不建议一次性重写成 TypeScript + 大型前端框架。推荐渐进式迁移:
+
+```text
+1. 先补测试和模块边界
+2. 引入 ESLint / Prettier
+3. 开启 TypeScript checkJs
+4. 核心模块改为 .ts
+5. 前端复杂度超过阈值后再引入 Vite + Vue/React
+```
+
+### 第一阶段:模块拆分
+
+目标不是马上引入框架,而是让 `server.js` 不再继续膨胀。
+
+```text
+src/
+  server.js
+  config/
+  routes/
+  services/
+  jobs/
+  assets/
+  crawl/
+  providers/
+  image/
+  storage/
+  db/
+  shared/
+```
+
+拆分顺序:
+
+1. `config`:读取、校验、默认值;
+2. `db`:SQLite 连接和 migration;
+3. `jobs`:任务状态机和队列;
+4. `assets`:资产索引、缩略图、下载;
+5. `crawl`:门店采集;
+6. `providers`:图片生成供应商适配;
+7. `routes`:HTTP 路由;
+8. `image`:图片处理。
+
+### TypeScript 路线
+
+先引入最小可用配置:
+
+```json
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "allowJs": true,
+    "checkJs": true,
+    "noEmit": true,
+    "strict": true,
+    "skipLibCheck": true
+  },
+  "include": ["src/**/*.js", "src/**/*.ts"]
+}
+```
+
+优先补充类型的模块:
+
+1. `config`;
+2. `db`;
+3. `jobs`;
+4. `assets`;
+5. `providers`;
+6. API 请求和响应。
+
+建议补充 `zod`:
+
+- 启动时校验配置;
+- API 请求体校验;
+- 供应商响应校验;
+- 数据库写入前校验。
+
+### 测试策略
+
+#### 单元测试
+
+优先覆盖:
+
+1. 路径安全;
+2. 文件名清洗;
+3. 配置校验;
+4. 提示词渲染;
+5. 模型参数构建;
+6. 任务状态机;
+7. 资产状态机;
+8. 图片尺寸解析。
+
+#### 集成测试
+
+覆盖 API:
+
+1. `/healthz`;
+2. `/readyz`;
+3. 设置读写;
+4. 输出根目录限制;
+5. 门店扫描;
+6. 生成任务创建;
+7. 任务状态查询;
+8. 资产筛选和下载。
+
+供应商和淘宝闪购接口必须 mock,不能在 CI 中真实调用或产生费用。
+
+#### E2E 测试
+
+使用 Playwright 覆盖:
+
+1. 启动进入门店采集;
+2. 添加店铺;
+3. 系统设置保存;
+4. 视觉生成页面扫描;
+5. 任务中心状态;
+6. 历史资产筛选;
+7. 悬浮放大和下载;
+8. 页面刷新后任务恢复。
+
+### CI/CD
+
+第一阶段 GitHub Actions:
+
+```text
+install → lint → typecheck → unit → build → integration → e2e → docker build
+```
+
+建议脚本:
+
+```json
+{
+  "scripts": {
+    "dev": "node --watch src/server.js",
+    "build": "vite build",
+    "lint": "eslint .",
+    "format": "prettier --write .",
+    "typecheck": "tsc --noEmit",
+    "test": "vitest run",
+    "test:e2e": "playwright test",
+    "db:migrate": "node src/db/migrate.js"
+  }
+}
+```
+
+分支策略:
+
+```text
+main        可发布
+develop     集成分支
+feature/*   功能分支
+fix/*       修复分支
+release/*   发布准备
+```
+
+发布流程:
+
+1. CI 全绿;
+2. 版本号更新;
+3. 生成 Changelog;
+4. 构建 Docker 镜像;
+5. 打 tag;
+6. 本地版发布压缩包或安装器;
+7. SaaS 版部署到预发环境;
+8. 预发验证后发布生产。
+
+### Docker 化
+
+本地版使用多阶段构建:
+
+```dockerfile
+FROM node:22-bookworm-slim AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+FROM node:22-bookworm-slim AS app
+WORKDIR /app
+ENV NODE_ENV=production
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+EXPOSE 5177
+CMD ["node", "server.js"]
+```
+
+`docker-compose.yml`:
+
+```yaml
+services:
+  app:
+    build: .
+    ports:
+      - "127.0.0.1:5177:5177"
+    environment:
+      NODE_ENV: production
+      HOST: 0.0.0.0
+    volumes:
+      - ./workspace:/app/workspace
+      - ./data:/app/data
+      - ./config.json:/app/config.json:ro
+    healthcheck:
+      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5177/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+```
+
+后续 SaaS 增加:
+
+```text
+postgres
+redis
+minio
+worker
+nginx / caddy
+```
+
+### 可观测性
+
+P2 阶段先落地本地可观测性:
+
+1. 结构化日志;
+2. request id;
+3. job id;
+4. 错误码;
+5. 日志文件轮转;
+6. `/metrics`;
+7. 任务耗时统计;
+8. 供应商成功率;
+9. 队列长度;
+10. 存储增长。
+
+核心指标:
+
+```text
+generation_jobs_total
+generation_jobs_failed_total
+generation_job_duration_seconds
+provider_request_duration_seconds
+provider_error_total
+queue_depth
+crawl_shop_success_total
+crawl_image_failed_total
+asset_storage_bytes
+```
+
+---
+
+## P3-A 模板库
+
+### 目标
+
+把提示词和视觉模板从分散配置升级为可复用、可版本化、可分发的模板库。
+
+### 数据模型
+
+```sql
+CREATE TABLE templates (
+  id TEXT PRIMARY KEY,
+  name TEXT NOT NULL,
+  description TEXT,
+  category TEXT NOT NULL,
+  kind TEXT NOT NULL,
+  latest_version INTEGER NOT NULL,
+  visibility TEXT NOT NULL,            -- private | workspace | public
+  status TEXT NOT NULL,
+  created_by TEXT,
+  created_at TEXT NOT NULL,
+  updated_at TEXT NOT NULL
+);
+
+CREATE TABLE template_versions (
+  id TEXT PRIMARY KEY,
+  template_id TEXT NOT NULL,
+  version INTEGER NOT NULL,
+  prompt TEXT NOT NULL,
+  negative_prompt TEXT,
+  variables_json TEXT NOT NULL,
+  default_params_json TEXT NOT NULL,
+  preview_asset_id TEXT,
+  changelog TEXT,
+  status TEXT NOT NULL,
+  created_at TEXT NOT NULL
+);
+```
+
+### 变量系统
+
+支持变量:
+
+```text
+{{shopName}}
+{{dishName}}
+{{brandColor}}
+{{brandStyle}}
+{{platform}}
+{{aspectRatio}}
+```
+
+渲染时必须校验:
+
+1. 必填变量;
+2. 变量类型;
+3. 变量长度;
+4. 禁用占位符残留;
+5. 模板版本;
+6. 渲染结果快照。
+
+### 功能
+
+1. 模板列表;
+2. 模板预览;
+3. 模板复制;
+4. 模板编辑;
+5. 版本对比;
+6. 默认模板;
+7. 门店覆盖模板;
+8. 模板试运行;
+9. 模板评分;
+10. 使用统计。
+
+### 验收标准
+
+- 每个生成任务都能追溯到模板 ID 和版本。
+- 修改模板不会影响历史任务。
+- 模板变量渲染后不允许出现未替换占位符。
+- 用户可以基于历史作品反向保存模板。
+
+---
+
+## P3-B 审核流
+
+### 目标
+
+让生成结果可以进入“待审核 → 通过 / 驳回 → 发布”的标准流程,适合团队和连锁门店运营。
+
+### 状态机
+
+```text
+generated → pending_review
+pending_review → approved
+pending_review → rejected
+rejected → regenerated
+approved → published
+published → archived
+```
+
+### 数据模型
+
+```sql
+CREATE TABLE reviews (
+  id TEXT PRIMARY KEY,
+  asset_id TEXT NOT NULL,
+  status TEXT NOT NULL,
+  reviewer_id TEXT,
+  comment TEXT,
+  reasons_json TEXT,
+  created_at TEXT NOT NULL,
+  updated_at TEXT NOT NULL
+);
+```
+
+### 审核规则
+
+1. 自动初审:
+   - 图片是否生成成功;
+   - 尺寸是否符合要求;
+   - 文件是否损坏;
+   - 是否包含禁止内容;
+   - 敏感词检查。
+2. 人工复审:
+   - 品牌一致性;
+   - LOGO 位置;
+   - 菜品是否失真;
+   - 文案是否正确;
+   - 是否符合平台规范。
+3. 驳回时必须选择原因:
+   - 品牌不一致;
+   - LOGO 错误;
+   - 菜品失真;
+   - 文案错误;
+   - 分辨率不足;
+   - 其他。
+
+### 功能
+
+1. 审核队列;
+2. 批量通过;
+3. 批量驳回;
+4. 审核评论;
+5. 对比原图和生成图;
+6. 驳回后一键重新生成;
+7. 审核历史;
+8. 责任人统计。
+
+### 验收标准
+
+- 每个资产的状态变化都有记录。
+- 驳回后能带着原因重新生成。
+- 团队成员只能处理自己有权限的工作空间。
+- 发布后的资产不可被普通删除操作直接影响。
+
+---
+
+## P3-C 开放 API
+
+### 目标
+
+允许门店系统、ERP、运营平台或自动化脚本安全调用生成和查询能力。
+
+### API 分层
+
+```text
+内部 UI API:/api/*
+开放 API:/open/v1/*
+Webhook:/open/v1/events/*
+```
+
+### 核心接口
+
+```text
+POST /open/v1/generations
+GET  /open/v1/generations/:id
+GET  /open/v1/assets/:id
+GET  /open/v1/shops
+GET  /open/v1/templates
+POST /open/v1/templates/:id/render
+```
+
+### 认证
+
+1. 开放 API Key 独立于 UI 登录态;
+2. Key 哈希存储;
+3. 只显示一次完整 Key;
+4. 支持设置:
+   - 过期时间;
+   - 权限范围;
+   - IP 白名单;
+   - QPS 限制;
+   - 日配额;
+   - 月配额。
+
+### 幂等
+
+创建生成任务必须支持:
+
+```http
+Idempotency-Key: <uuid>
+```
+
+服务端根据 Key 和请求 hash 判断:
+
+- 首次请求:创建任务;
+- 重复请求:返回原任务;
+- 冲突请求:返回 409。
+
+### 限流和配额
+
+```sql
+CREATE TABLE api_keys (
+  id TEXT PRIMARY KEY,
+  tenant_id TEXT NOT NULL,
+  name TEXT NOT NULL,
+  key_hash TEXT NOT NULL,
+  scopes_json TEXT NOT NULL,
+  rate_limit_per_minute INTEGER,
+  daily_quota INTEGER,
+  monthly_quota INTEGER,
+  expires_at TEXT,
+  last_used_at TEXT,
+  status TEXT NOT NULL,
+  created_at TEXT NOT NULL
+);
+```
+
+### Webhook
+
+事件类型:
+
+```text
+generation.succeeded
+generation.failed
+generation.canceled
+asset.approved
+asset.rejected
+asset.published
+```
+
+Webhook 要求:
+
+1. HMAC 签名;
+2. 重试退避;
+3. 事件幂等;
+4. 可查询投递日志;
+5. 支持手动重发。
+
+### 验收标准
+
+- 第三方可以用 API Key 安全创建任务。
+- 重复 Idempotency-Key 不会重复扣费。
+- 超过配额返回明确错误码。
+- 所有开放 API 操作都有审计日志。
+
+---
+
+## P3-D 模型市场
+
+### 目标
+
+把固定模型列表升级为可配置、可评价、可路由的模型市场,降低供应商锁定风险。
+
+### 数据模型
+
+```sql
+CREATE TABLE models (
+  id TEXT PRIMARY KEY,
+  provider TEXT NOT NULL,
+  slug TEXT NOT NULL,
+  display_name TEXT NOT NULL,
+  description TEXT,
+  capabilities_json TEXT NOT NULL,
+  price_json TEXT NOT NULL,
+  status TEXT NOT NULL,
+  health_status TEXT,
+  created_at TEXT NOT NULL,
+  updated_at TEXT NOT NULL
+);
+
+CREATE TABLE model_routes (
+  id TEXT PRIMARY KEY,
+  name TEXT NOT NULL,
+  strategy TEXT NOT NULL,              -- manual | best | price | speed | success_rate
+  fallback_model_ids_json TEXT NOT NULL,
+  status TEXT NOT NULL
+);
+```
+
+### 模型能力
+
+每个模型声明:
+
+```json
+{
+  "maxImages": 4,
+  "sizes": ["1K", "2K", "4K"],
+  "aspectRatios": ["1:1", "4:3", "16:9"],
+  "supportsReferenceImage": true,
+  "supportsLogoOverlay": true,
+  "supportsNegativePrompt": false,
+  "estimatedSeconds": 45,
+  "billing": "per_image"
+}
+```
+
+### 路由策略
+
+1. 指定模型;
+2. 综合最优;
+3. 价格优先;
+4. 速度优先;
+5. 成功率优先;
+6. 指定回退链。
+
+### 市场信息
+
+1. 模型名称;
+2. 提供商;
+3. 单价;
+4. 预计耗时;
+5. 支持比例;
+6. 支持尺寸;
+7. 参考图数量;
+8. 成功率;
+9. 健康状态;
+10. 最近生成示例。
+
+### 验收标准
+
+- 新增模型不需要重写业务流程。
+- 模型不可用时能自动或手动切换回退模型。
+- 每次生成记录实际使用的模型和成本。
+- 模型失败原因能归类为限流、余额不足、参数错误、供应商故障等。
+
+---
+
+## 实施节奏
+
+### Sprint 1:任务持久化
+
+- 本地单店阶段先用 `.genpic/tasks.json` 保存任务快照;
+- 生成任务创建、进行中和终态状态落盘;
+- 服务重启后恢复供应商轮询,无法安全恢复的排队任务标记失败;
+- 任务中心 MVP 支持状态统计、筛选和结果查看。
+- 后续批量/SaaS 阶段再迁移到 SQLite 的 `jobs` / `job_events` 模型。
+
+### Sprint 2:任务体验
+
+- SSE;
+- 重试 / 取消;
+- 批量任务汇总;
+- 失败原因展示;
+- 任务日志。
+
+### Sprint 3:配置引导
+
+- 配置状态接口;
+- API / Cookie / 目录测试;
+- Onboarding 弹窗;
+- 侧边栏健康状态。
+
+### Sprint 4:资产库
+
+- assets / asset_versions;
+- 历史创作迁移;
+- 筛选、收藏、详情;
+- 回收站;
+- 稳定下载。
+
+### Sprint 5:工程质量
+
+- 模块拆分;
+- ESLint / Prettier;
+- TypeScript checkJs;
+- 单元和集成测试;
+- GitHub Actions;
+- Docker。
+
+### Sprint 6:模板库
+
+- 模板和版本表;
+- 变量渲染;
+- 模板选择;
+- 历史任务版本追溯;
+- 模板保存入口。
+
+### Sprint 7:审核流
+
+- 资产状态机;
+- 审核队列;
+- 批量操作;
+- 驳回原因;
+- 重新生成联动。
+
+### Sprint 8:开放 API
+
+- API Key;
+- 幂等;
+- 限流;
+- 开放任务和资产接口;
+- Webhook。
+
+### Sprint 9:模型市场
+
+- 模型注册;
+- 能力声明;
+- 路由策略;
+- 健康检查;
+- 成本与成功率面板。
+
+---
+
+## 当前阶段建议
+
+近期只做三件事:
+
+1. **任务持久化 + 任务中心 MVP**:这是所有批量生成体验的基础。
+2. **配置引导 + 健康检查**:减少 API Key、Cookie、目录配置错误。
+3. **资产库数据化**:把历史创作从文件扫描升级为数据库索引。
+
+TypeScript、CI/CD、Docker 应该与任务中心和资产库并行推进,但不要先做大规模前端重写。P3 能力必须等 P2 数据模型稳定后再开启,否则模板、审核、开放 API 和模型市场都会建立在脆弱的文件扫描和内存任务状态上。

+ 10 - 2
public/app.js

@@ -4,6 +4,7 @@
   const views = {
     crawl: document.getElementById('viewCrawl'),
     gen: document.getElementById('viewGen'),
+    tasks: document.getElementById('viewTasks'),
     history: document.getElementById('viewHistory'),
     settings: document.getElementById('viewSettings'),
   };
@@ -43,7 +44,11 @@
     document.getElementById('settingsRootDir').value = settings.rootDir || '';
     document.getElementById('pageSizeInput').value = settings.pageSize || 50;
     document.getElementById('delayInput').value = settings.delayMs ?? 300;
-    document.getElementById('settingsCookie').value = settings.cookie || '';
+    const cookieInput = document.getElementById('settingsCookie');
+    cookieInput.value = '';
+    cookieInput.placeholder = settings.cookieConfigured
+      ? '已配置,输入新 Cookie 可更换'
+      : '登录 melody.shop.ele.me 后从 DevTools 复制';
     await loadModels(settings.model);
     showSettingsStatus('已同步');
   }
@@ -62,7 +67,9 @@
           rootDir: document.getElementById('settingsRootDir').value.trim(),
           pageSize: Number(document.getElementById('pageSizeInput').value),
           delayMs: Number(document.getElementById('delayInput').value),
-          cookie: document.getElementById('settingsCookie').value.trim(),
+          ...(document.getElementById('settingsCookie').value.trim()
+            ? { cookie: document.getElementById('settingsCookie').value.trim() }
+            : {}),
         }),
       });
       const data = await response.json();
@@ -144,6 +151,7 @@
     navItems.forEach((n) => n.classList.toggle('active', n.dataset.view === name));
     if (name === 'crawl' && window.shanCrawlInit) window.shanCrawlInit();
     if (name === 'gen' && window.shanGenInit) window.shanGenInit();
+    if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit();
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'settings') initSettings();
   }

+ 48 - 7
public/index.html

@@ -37,6 +37,7 @@
 
     <div class="nav-section-label">生成</div>
     <button class="nav-item" data-view="gen"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.9L19 9.7l-4.3 3 1.2 5.3-3.9-3-3.9 3 1.2-5.3-4.3-3 5.2-1.8z"/></svg></span><span>视觉生成</span></button>
+    <button class="nav-item" data-view="tasks"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M4 12h16M4 17h7"/><circle cx="18" cy="7" r="2"/><circle cx="15" cy="17" r="2"/></svg></span><span>任务中心</span></button>
     <button class="nav-item" data-view="history"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="1.8"/><path d="M21 16l-5-5-6 6"/></svg></span><span>历史创作</span></button>
 
     <div class="nav-section-label">设置</div>
@@ -187,6 +188,43 @@
       </main>
     </div>
 
+    <!-- ==================== 历史创作 ==================== -->
+    <div id="viewTasks" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>任务中心</h1>
+            <div class="sub">跟踪生成任务状态,服务重启后可恢复进行中的生成任务</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnRefreshTasks">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
+              刷新
+            </button>
+          </div>
+        </div>
+        <div class="tasks-overview panel panel-designed">
+          <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M4 12h16M4 17h7"/><circle cx="18" cy="7" r="2"/><circle cx="15" cy="17" r="2"/></svg></span>
+          <div class="tasks-stats">
+            <div class="task-stat"><span>全部</span><strong id="taskCountAll">0</strong></div>
+            <div class="task-stat running"><span>进行中</span><strong id="taskCountRunning">0</strong></div>
+            <div class="task-stat queued"><span>排队</span><strong id="taskCountQueued">0</strong></div>
+            <div class="task-stat success"><span>成功</span><strong id="taskCountSuccess">0</strong></div>
+            <div class="task-stat failed"><span>失败</span><strong id="taskCountFailed">0</strong></div>
+          </div>
+          <div class="tasks-filters" id="taskFilters">
+            <button class="history-filter active" data-filter="all">全部</button>
+            <button class="history-filter" data-filter="running">进行中</button>
+            <button class="history-filter" data-filter="queued">排队中</button>
+            <button class="history-filter" data-filter="success">已完成</button>
+            <button class="history-filter" data-filter="failed">失败</button>
+          </div>
+        </div>
+        <div class="history-summary" id="taskSummary">正在加载任务…</div>
+        <div class="task-list" id="taskList"></div>
+      </main>
+    </div>
+
     <!-- ==================== 历史创作 ==================== -->
     <div id="viewHistory" class="view">
       <main class="content">
@@ -237,7 +275,13 @@
             <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg></span>
             <div class="panel-body">
               <div class="field">
-                <label>API Key<span class="req">*</span></label>
+                <div class="field-label-row">
+                  <label>API Key<span class="req">*</span></label>
+                  <a class="settings-link settings-link-compact" href="https://o-hubs.com/api/console/keys" target="_blank" rel="noopener noreferrer">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg>
+                    <span>获取 API Key</span>
+                  </a>
+                </div>
                 <input type="text" id="settingsApiKey" placeholder="sk-...">
               </div>
               <div class="field">
@@ -249,10 +293,6 @@
 	                <select id="settingsModel"></select>
 	              </div>
 	              <div class="settings-links">
-	                <a class="settings-link" href="https://o-hubs.com/api/console/keys" target="_blank" rel="noopener noreferrer">
-	                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg>
-	                  <span>获取 API Key</span>
-	                </a>
 	                <a class="settings-link" href="https://o-hubs.com/home" target="_blank" rel="noopener noreferrer">
 	                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="3"/><path d="M3 10h18"/><path d="M7 15h3"/></svg>
 	                  <span>充值</span>
@@ -281,8 +321,8 @@
               </div>
               <div class="field">
                 <label>Cookie(淘宝闪购)</label>
-                <textarea id="settingsCookie" rows="3" placeholder="登录 melody.shop.ele.me 后从 DevTools 复制"></textarea>
-                <div class="help">会话过期时需重新获取并更新</div>
+	                <textarea id="settingsCookie" rows="3" placeholder="登录 melody.shop.ele.me 后从 DevTools 复制"></textarea>
+	                <div class="help">Cookie 保存后不再回显;会话过期时粘贴新值即可更新</div>
               </div>
 	              <div style="display:flex;align-items:center;gap:8px;">
 	                <button class="btn btn-primary btn-small" id="btnSaveSettings">保存设置</button>
@@ -338,6 +378,7 @@
 
 <script src="/crawl.js"></script>
 <script src="/gen.js"></script>
+<script src="/tasks.js"></script>
 <script src="/history.js"></script>
 <script src="/app.js"></script>
 </body>

+ 41 - 1
public/style.css

@@ -161,6 +161,8 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .field .help { font-size: 11px; color: var(--text-3); }
 .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
 .field-row .field input { width: 100%; min-width: 0; }
+.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.field-label-row label { flex: 1 1 auto; margin: 0; }
 .settings-workspace { display: grid; grid-template-columns: minmax(340px, 420px) minmax(0, 1fr); gap: 16px; align-items: stretch; }
 .settings-forms { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
 .settings-forms .field textarea { min-height: 78px; resize: vertical; line-height: 1.5; }
@@ -181,7 +183,7 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .settings-log .panel-title,
 .settings-log .log-box { position: relative; z-index: 1; }
 .settings-actions { display: flex; align-items: center; gap: 8px; }
-.settings-links { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.settings-links { display: grid; grid-template-columns: 1fr; gap: 8px; }
 .settings-link {
   display: inline-flex; align-items: center; justify-content: center; gap: 7px;
   min-height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
@@ -193,6 +195,8 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
   border-color: var(--accent); color: var(--accent); background: #fff;
   box-shadow: 0 0 0 2px var(--focus-ring);
 }
+.settings-link-compact { flex: 0 0 auto; min-height: 24px; padding: 0 8px; color: var(--accent); font-size: 11px; }
+.settings-link-compact:hover { transform: translateY(-1px); }
 .settings-log { height: auto; min-height: 360px; }
 .settings-log .log-box { min-height: 0; }
 
@@ -633,6 +637,42 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .dish-card .meta-row .price { color: var(--accent); font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; }
 
 /* history gallery */
+.tasks-overview { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; margin-bottom: 14px; }
+.tasks-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; flex: 1 1 auto; min-width: 0; }
+.task-stat { min-width: 0; padding: 12px 14px; border: 1px solid var(--border-light); border-radius: 12px; background: linear-gradient(135deg, #fff, #faf8f5); }
+.task-stat span { display: block; color: var(--text-3); font-size: 11px; font-weight: 600; }
+.task-stat strong { display: block; margin-top: 5px; color: var(--text); font-size: 20px; line-height: 1; font-variant-numeric: tabular-nums; }
+.task-stat.running strong { color: var(--info); }
+.task-stat.queued strong { color: var(--warn); }
+.task-stat.success strong { color: var(--ok); }
+.task-stat.failed strong { color: var(--fail); }
+.tasks-filters { flex: 0 0 auto; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 7px; max-width: 330px; }
+.task-list { display: flex; flex-direction: column; gap: 10px; padding-bottom: 60px; }
+.task-list.empty { min-height: clamp(360px, calc(100vh - 320px), 640px); }
+.task-card { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 16px; background: var(--card-bg); border: 1px solid var(--border-light); border-radius: 12px; transition: border-color .15s var(--ease-out), box-shadow .15s var(--ease-out), transform .15s var(--ease-out); }
+.task-card:hover { border-color: var(--border); box-shadow: 0 10px 24px rgba(31,27,24,.07); transform: translateY(-1px); }
+.task-main { flex: 1 1 auto; min-width: 0; }
+.task-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.task-title { color: var(--text); font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.task-badge { flex: 0 0 auto; }
+.task-badge.running, .task-badge.submitted { background: var(--info-bg); color: var(--info); }
+.task-badge.queued { background: var(--warn-bg); color: var(--warn); }
+.task-badge.success { background: var(--ok-bg); color: var(--ok); }
+.task-badge.failed, .task-badge.timeout { background: var(--fail-bg); color: var(--fail); }
+.task-progress { height: 4px; margin: 10px 0 8px; border-radius: 999px; background: var(--border-light); overflow: hidden; }
+.task-progress-bar { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-dark)); transition: width .3s var(--ease-out); }
+.task-meta { color: var(--text-3); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.task-notice { margin-top: 7px; padding: 7px 9px; border-radius: 8px; font-size: 11.5px; line-height: 1.4; }
+.task-notice.warning { background: var(--warn-bg); color: #8b6508; }
+.task-notice.error { background: var(--fail-bg); color: var(--fail); }
+.task-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 10px; }
+.task-status-detail { color: var(--text-3); font-size: 11.5px; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.task-empty { flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 28px; text-align: center; }
+.task-empty-icon { width: 76px; height: 76px; margin: 0 auto 16px; color: var(--text-3); opacity: .16; }
+.task-empty-icon svg { width: 100%; height: 100%; }
+.task-empty p { margin: 0; color: var(--text-2); font-size: 14px; font-weight: 650; }
+.task-empty small { display: block; margin-top: 6px; color: var(--text-3); font-size: 11.5px; }
+
 .history-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 14px; margin-bottom: 14px; }
 .history-search { position: relative; flex: 0 1 320px; min-width: 220px; }
 .history-search svg { position: absolute; left: 11px; top: 50%; width: 15px; height: 15px; transform: translateY(-50%); color: var(--text-3); pointer-events: none; }

+ 204 - 0
public/tasks.js

@@ -0,0 +1,204 @@
+'use strict';
+
+(function () {
+  const state = {
+    initialized: false,
+    loading: false,
+    root: '',
+    tasks: [],
+    filter: 'all',
+    timer: null,
+  };
+
+  const STATUS_LABELS = {
+    queued: '排队中',
+    submitted: '已提交',
+    running: '进行中',
+    success: '已完成',
+    failed: '失败',
+    canceled: '已取消',
+    timeout: '已超时',
+  };
+  const KIND_LABELS = {
+    logo: 'LOGO',
+    banner: '海报',
+    signage: '招牌',
+    sticker: '贴纸',
+    dish: '菜品',
+  };
+
+  function el(tag, className, text) {
+    const node = document.createElement(tag);
+    if (className) node.className = className;
+    if (text !== undefined) node.textContent = text;
+    return node;
+  }
+
+  function formatTime(value) {
+    if (!value) return '';
+    const date = new Date(value);
+    const pad = (n) => String(n).padStart(2, '0');
+    return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+  }
+
+  function formatBytes(size) {
+    if (!Number.isFinite(size) || size <= 0) return '';
+    if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
+    return `${(size / 1024 / 1024).toFixed(1)} MB`;
+  }
+
+  function progressValue(task) {
+    const parsed = Number.parseInt(String(task.progress || '').replace(/[^\d]/g, ''), 10);
+    if (task.status === 'success') return 100;
+    if (Number.isFinite(parsed)) return Math.min(100, Math.max(0, parsed));
+    return task.status === 'queued' ? 0 : 35;
+  }
+
+  function getFilteredTasks() {
+    if (state.filter === 'all') return state.tasks;
+    if (state.filter === 'running') {
+      return state.tasks.filter((task) => task.status === 'running' || task.status === 'submitted');
+    }
+    return state.tasks.filter((task) => task.status === state.filter);
+  }
+
+  function setCount(id, value) {
+    document.getElementById(id).textContent = String(value || 0);
+  }
+
+  function renderSummary() {
+    const counts = { all: 0, running: 0, queued: 0, success: 0, failed: 0 };
+    state.tasks.forEach((task) => {
+      counts.all += 1;
+      if (task.status === 'running' || task.status === 'submitted') counts.running += 1;
+      else if (task.status === 'queued') counts.queued += 1;
+      else if (task.status === 'success') counts.success += 1;
+      else if (task.status === 'failed' || task.status === 'timeout') counts.failed += 1;
+    });
+    setCount('taskCountAll', counts.all);
+    setCount('taskCountRunning', counts.running);
+    setCount('taskCountQueued', counts.queued);
+    setCount('taskCountSuccess', counts.success);
+    setCount('taskCountFailed', counts.failed);
+    document.getElementById('taskSummary').textContent = state.loading
+      ? '正在加载任务…'
+      : `共 ${counts.all} 个任务 · ${counts.running} 个进行中 · ${counts.failed} 个失败`;
+  }
+
+  function buildTaskCard(task) {
+    const card = el('article', `task-card status-${task.status}`);
+    const main = el('div', 'task-main');
+
+    const titleRow = el('div', 'task-title-row');
+    const title = el('div', 'task-title');
+    title.textContent = `${task.shopName || '未知门店'} · ${KIND_LABELS[task.kind] || task.name || task.kind}`;
+    const badge = el('span', `badge task-badge ${task.status}`);
+    badge.textContent = STATUS_LABELS[task.status] || task.status;
+    titleRow.append(title, badge);
+
+    const meta = el('div', 'task-meta');
+    meta.textContent = [
+      task.model || '默认模型',
+      formatTime(task.createdAt),
+      task.cost !== null && task.cost !== undefined ? `费用 ${task.cost}` : '',
+      task.providerTaskId ? `任务 ${String(task.providerTaskId).slice(0, 10)}…` : '',
+    ].filter(Boolean).join(' · ');
+
+    const progress = el('div', 'task-progress');
+    const bar = el('span', 'task-progress-bar');
+    bar.style.width = `${progressValue(task)}%`;
+    progress.appendChild(bar);
+
+    main.append(titleRow, progress, meta);
+    if (task.warning) main.appendChild(el('div', 'task-notice warning', task.warning));
+    if (task.error) main.appendChild(el('div', 'task-notice error', task.error));
+
+    const actions = el('div', 'task-actions');
+    const right = el('div', 'task-status-detail');
+    right.textContent = `${task.progress || progressValue(task) + '%'}${task.fileSizeBytes ? ` · ${formatBytes(task.fileSizeBytes)}` : ''}`;
+    actions.appendChild(right);
+    if (task.resultUrl) {
+      const open = el('a', 'btn btn-ghost btn-small', '查看结果');
+      open.href = task.resultUrl;
+      open.target = '_blank';
+      open.rel = 'noopener noreferrer';
+      actions.appendChild(open);
+    }
+
+    card.append(main, actions);
+    return card;
+  }
+
+  function renderTasks() {
+    const list = document.getElementById('taskList');
+    const tasks = getFilteredTasks();
+    list.innerHTML = '';
+    list.classList.toggle('empty', tasks.length === 0);
+    if (!tasks.length) {
+      const empty = el('div', 'task-empty');
+      const icon = el('div', 'task-empty-icon');
+      icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M4 12h16M4 17h7"/><circle cx="18" cy="7" r="2"/><circle cx="15" cy="17" r="2"/></svg>';
+      const text = el('p', '', state.loading ? '正在加载任务' : '暂无匹配任务');
+      const help = el('small', '', state.loading ? '请稍候' : '提交生成任务后会自动显示在这里');
+      empty.append(icon, text, help);
+      list.appendChild(empty);
+      return;
+    }
+    tasks.forEach((task) => list.appendChild(buildTaskCard(task)));
+  }
+
+  function render() {
+    renderSummary();
+    renderTasks();
+  }
+
+  async function loadTasks() {
+    if (state.loading) return;
+    state.loading = true;
+    render();
+    try {
+      if (!state.root) {
+        const rootResponse = await fetch('/api/default-root');
+        const rootData = await rootResponse.json();
+        state.root = rootData.root;
+      }
+      const url = `/api/tasks?root=${encodeURIComponent(state.root)}`;
+      const response = await fetch(url);
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.error || '任务列表加载失败');
+      state.tasks = data.tasks || [];
+    } catch (error) {
+      state.tasks = [];
+      document.getElementById('taskSummary').textContent = error.message;
+    } finally {
+      state.loading = false;
+      render();
+    }
+  }
+
+  function bindEvents() {
+    document.getElementById('btnRefreshTasks').addEventListener('click', loadTasks);
+    document.getElementById('taskFilters').addEventListener('click', (event) => {
+      const button = event.target.closest('[data-filter]');
+      if (!button) return;
+      state.filter = button.dataset.filter;
+      document.querySelectorAll('#taskFilters .history-filter').forEach((item) => {
+        item.classList.toggle('active', item === button);
+      });
+      renderTasks();
+    });
+  }
+
+  window.shanTasksInit = function () {
+    if (!state.initialized) {
+      state.initialized = true;
+      bindEvents();
+    }
+    loadTasks();
+    if (!state.timer) {
+      state.timer = setInterval(() => {
+        if (document.getElementById('viewTasks').classList.contains('active')) loadTasks();
+      }, 3000);
+    }
+  };
+})();

+ 232 - 22
server.js

@@ -23,6 +23,35 @@ function getWorkspaceRoot() {
   const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
   return path.resolve(configuredRoot);
 }
+const HOST = process.env.HOST || '127.0.0.1';
+const MAX_BODY_BYTES = 32 * 1024 * 1024;
+
+function isWorkspaceRoot(candidate) {
+  try {
+    return !!candidate && path.resolve(String(candidate)) === getWorkspaceRoot();
+  } catch {
+    return false;
+  }
+}
+
+function isWorkspacePath(candidate) {
+  try {
+    const resolved = path.resolve(String(candidate));
+    const root = getWorkspaceRoot();
+    return resolved === root || resolved.startsWith(root + path.sep);
+  } catch {
+    return false;
+  }
+}
+
+function safeShopDir(root, folderName) {
+  if (!isWorkspaceRoot(root) || typeof folderName !== 'string' ||
+      !folderName || folderName === '.' || folderName === '..' ||
+      folderName.includes('/') || folderName.includes('\\')) return null;
+  const rootDir = path.resolve(root);
+  const shopDir = path.resolve(rootDir, folderName);
+  return shopDir.startsWith(rootDir + path.sep) ? shopDir : null;
+}
 fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
 const SESSION_PATH = path.join(__dirname, 'session.json');
 const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json');
@@ -77,7 +106,15 @@ function sanitizeFileName(name) {
 function readBody(req) {
   return new Promise((resolve, reject) => {
     const chunks = [];
+    let size = 0;
     req.on('data', (c) => chunks.push(c));
+    req.on('data', (c) => {
+      size += c.length;
+      if (size > MAX_BODY_BYTES) {
+        req.destroy(new Error('请求体过大'));
+        reject(new Error('请求体过大'));
+      }
+    });
     req.on('end', () => {
       if (!chunks.length) return resolve({});
       try {
@@ -262,6 +299,7 @@ function handleShopsManual(body, res) {
 }
 
 function handleShopsList(root, res) {
+  if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
   const completed = root && fs.existsSync(root) ? readCompletionState(root) : {};
   sendJson(res, 200, {
     shops: shopList.map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })),
@@ -338,6 +376,7 @@ async function startCrawl(body, res) {
   if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
   if (!session.cookie || !getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
   const root = String((body && body.root) || getWorkspaceRoot()).trim();
+  if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' });
   if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
     return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
   }
@@ -843,6 +882,77 @@ function downloadBuffer(urlStr, redirectsLeft = 5) {
 const jobs = new Map();
 const pendingQueue = [];
 let activeCount = 0;
+const persistedTasks = new Map();
+let taskStoreLoaded = false;
+
+function getTaskStoreFile() {
+  return path.join(getWorkspaceRoot(), '.genpic', 'tasks.json');
+}
+
+function taskSnapshot(job) {
+  const { images, ...safeJob } = job;
+  return {
+    ...safeJob,
+    imageCount: Array.isArray(images) ? images.length : 0,
+    updatedAt: Date.now(),
+  };
+}
+
+function persistJob(job) {
+  if (!job || !job.id) return;
+  persistedTasks.set(job.id, taskSnapshot(job));
+  const storeFile = getTaskStoreFile();
+  fs.mkdirSync(path.dirname(storeFile), { recursive: true });
+  const tempPath = `${storeFile}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, JSON.stringify({
+    version: 1,
+    updatedAt: new Date().toISOString(),
+    tasks: Array.from(persistedTasks.values()),
+  }, null, 2));
+  fs.renameSync(tempPath, storeFile);
+}
+
+function loadPersistedJobs() {
+  if (taskStoreLoaded) return;
+  taskStoreLoaded = true;
+  const storeFile = getTaskStoreFile();
+  let parsed;
+  try {
+    parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8'));
+  } catch {
+    return;
+  }
+  if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return;
+  for (const raw of parsed.tasks) {
+    if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue;
+    const job = {
+      ...raw,
+      images: [],
+      compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [],
+    };
+    if (!MODEL_IDS.has(job.model)) job.model = config.model;
+    jobs.set(job.id, job);
+    persistedTasks.set(job.id, taskSnapshot(job));
+  }
+
+  for (const job of jobs.values()) {
+    if (job.state === 'queued') {
+      job.state = 'failed';
+      job.finishedAt = Date.now();
+      job.error = '服务重启前任务仍在排队,请重新提交';
+      persistJob(job);
+    } else if (job.state === 'running' || job.state === 'submitted') {
+      if (!job.taskId) {
+        job.state = 'failed';
+        job.finishedAt = Date.now();
+        job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
+        persistJob(job);
+      } else {
+        runJob(job);
+      }
+    }
+  }
+}
 
 function createJob(opts) {
   const id = crypto.randomUUID();
@@ -873,8 +983,11 @@ function createJob(opts) {
     error: null,
     warning: opts.warning || null,
     createdAt: Date.now(),
+    startedAt: null,
+    finishedAt: null,
   };
   jobs.set(id, job);
+  persistJob(job);
   const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
   pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
   if (activeCount < config.maxConcurrentJobs) {
@@ -886,8 +999,14 @@ function createJob(opts) {
 }
 
 function startJob(job) {
+  runJob(job);
+}
+
+function runJob(job) {
   activeCount++;
   job.state = 'running';
+  job.startedAt = job.startedAt || Date.now();
+  persistJob(job);
   processJob(job)
     .catch((e) => {
       job.state = 'failed';
@@ -897,6 +1016,8 @@ function startJob(job) {
       const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
       if (job.state === 'success') pushSystemLog(`生成完成:${jobTitle}`, 'success');
       else if (job.state === 'failed') pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
+      job.finishedAt = Date.now();
+      persistJob(job);
       activeCount--;
       const nextId = pendingQueue.shift();
       if (nextId) {
@@ -940,21 +1061,25 @@ function buildParamsForModel(modelId, job) {
 }
 
 async function processJob(job) {
-  const body = {
-    model: job.model,
-    prompt: job.prompt,
-    params: buildParamsForModel(job.model, job),
-  };
+  const resuming = !!job.taskId;
+  if (!resuming) {
+    const body = {
+      model: job.model,
+      prompt: job.prompt,
+      params: buildParamsForModel(job.model, job),
+    };
 
-  const submit = await apiRequest('POST', '/v1/media/generate', body);
-  const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
-  if (submit.statusCode !== 200 || !submit.json || !taskId) {
-    throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
+    const submit = await apiRequest('POST', '/v1/media/generate', body);
+    const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
+    if (submit.statusCode !== 200 || !submit.json || !taskId) {
+      throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
+    }
+    job.taskId = taskId;
+    persistJob(job);
   }
-  job.taskId = taskId;
 
-  await sleep(7000);
-  const start = Date.now();
+  await sleep(resuming ? 1000 : 7000);
+  const start = job.startedAt || Date.now();
   const TIMEOUT_MS = 10 * 60 * 1000;
   for (;;) {
     if (Date.now() - start > TIMEOUT_MS) {
@@ -973,6 +1098,7 @@ async function processJob(job) {
     }
     job.progress = String(status.progress ?? job.progress);
     if (status.cost !== undefined) job.cost = status.cost;
+    persistJob(job);
 
     if (status.is_final) {
       if (status.state === 'success' && status.result_url) {
@@ -1093,6 +1219,7 @@ function appendCreationHistory(outputDir, record) {
 
 function handleScan(query, res) {
   const root = query.root;
+  if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' });
   if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
     return sendJson(res, 400, { error: '目录不存在: ' + root });
   }
@@ -1178,7 +1305,7 @@ function getCreationKind(directoryName, fileName) {
 
 function handleCreations(query, res) {
   const root = query.root;
-  if (!root) return sendJson(res, 400, { error: '参数不完整' });
+  if (!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 });
@@ -1293,6 +1420,10 @@ function handleFiles(query, res) {
     return res.end('missing path');
   }
   const resolved = path.resolve(p);
+  if (!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');
@@ -1312,7 +1443,8 @@ async function handleGenerateBrand(body, res) {
   if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
     return sendJson(res, 400, { error: '参数不完整' });
   }
-  const shopDir = path.join(root, folderName);
+  const shopDir = safeShopDir(root, folderName);
+  if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
   // logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable
   const shouldAddLogo = !!addLogo && kind !== 'logo';
   const { images, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
@@ -1343,10 +1475,15 @@ async function handleGenerateDish(body, res) {
   if (!root || !folderName || !dishFile || !prompt) {
     return sendJson(res, 400, { error: '参数不完整' });
   }
-  const shopDir = path.join(root, folderName);
+  const shopDir = safeShopDir(root, folderName);
+  if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
+  const dishPath = path.resolve(shopDir, dishFile);
+  if (!isWorkspacePath(dishPath) || !dishPath.startsWith(shopDir + path.sep)) {
+    return sendJson(res, 403, { error: '菜品文件无效' });
+  }
   let dishImage;
   try {
-    dishImage = readAsDataUri(path.join(shopDir, dishFile));
+    dishImage = readAsDataUri(dishPath);
   } catch (e) {
     return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
   }
@@ -1398,10 +1535,57 @@ function handleJob(pathname, res) {
   });
 }
 
+function publicTask(job) {
+  return {
+    id: job.id,
+    type: 'generation',
+    status: job.state,
+    root: job.root,
+    folderName: job.folderName,
+    shopName: job.shopName,
+    dishName: job.dishName,
+    name: job.dishName || job.kind,
+    kind: job.kind,
+    model: job.model,
+    providerTaskId: job.taskId,
+    progress: job.progress,
+    cost: job.cost,
+    resultUrl: job.resultUrl,
+    remoteUrl: job.remoteUrl,
+    width: job.width,
+    height: job.height,
+    fileSizeBytes: job.fileSizeBytes,
+    error: job.error,
+    warning: job.warning,
+    createdAt: job.createdAt,
+    startedAt: job.startedAt,
+    finishedAt: job.finishedAt,
+    updatedAt: job.updatedAt || job.createdAt,
+  };
+}
+
+function handleTasks(query, res) {
+  if (query.root && !isWorkspaceRoot(query.root)) {
+    return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
+  }
+  const root = getWorkspaceRoot();
+  const tasks = Array.from(jobs.values())
+    .filter((job) => path.resolve(job.root) === root)
+    .sort((a, b) => b.createdAt - a.createdAt)
+    .map(publicTask);
+  const summary = tasks.reduce((acc, task) => {
+    acc[task.status] = (acc[task.status] || 0) + 1;
+    return acc;
+  }, { all: tasks.length });
+  sendJson(res, 200, { root, summary, tasks });
+}
+
 async function handleSaveConfig(body, res) {
   const { root, folderName, shopName, prompts } = body;
   if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
-  const dir = path.join(root, folderName, '_生成图片');
+  const shopDir = safeShopDir(root, folderName);
+  if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
+  const dir = path.join(shopDir, '_生成图片');
   fs.mkdirSync(dir, { recursive: true });
   const cfgPath = path.join(dir, 'prompt-config.json');
   let existing = {};
@@ -1455,6 +1639,7 @@ async function handleSavePosterCanvas(body, res) {
   }
 
   const resolvedRoot = path.resolve(root);
+  if (!isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
   const shopDir = path.resolve(resolvedRoot, folderName);
   if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
     return sendJson(res, 400, { error: '店铺目录无效' });
@@ -1494,6 +1679,7 @@ const STATIC_MAP = {
   '/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'],
   '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
   '/history.js': ['history.js', 'application/javascript; charset=utf-8'],
+  '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'],
   '/style.css': ['style.css', 'text/css; charset=utf-8'],
 };
 
@@ -1545,8 +1731,28 @@ const server = http.createServer((req, res) => {
   if (req.method === 'GET' && pathname === '/api/default-root') {
     return sendJson(res, 200, { root: getWorkspaceRoot() });
   }
+  if (req.method === 'GET' && pathname === '/healthz') {
+    return sendJson(res, 200, { ok: true, workspace: getWorkspaceRoot() });
+  }
+  if (req.method === 'GET' && pathname === '/readyz') {
+    return sendJson(res, 200, {
+      ok: !!config.apiKey,
+      workspaceReady: fs.existsSync(getWorkspaceRoot()),
+      crawlReady: !!getKsid(),
+    });
+  }
+  if (req.method === 'GET' && pathname === '/api/tasks') {
+    try {
+      return handleTasks(query, res);
+    } catch (e) {
+      return respond500(e);
+    }
+  }
   if (req.method === 'GET' && pathname === '/api/session') {
-    return sendJson(res, 200, session);
+    return sendJson(res, 200, {
+      cookieConfigured: !!session.cookie,
+      ksidConfigured: !!getKsid(),
+    });
   }
   if (req.method === 'POST' && pathname === '/api/session') {
     return readBody(req)
@@ -1561,6 +1767,7 @@ const server = http.createServer((req, res) => {
   if (req.method === 'GET' && pathname === '/api/shops') {
     const encodedRoot = parsedUrl.searchParams.get('root');
     const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : '';
+    if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
     return handleShopsList(root, res);
   }
   if (req.method === 'POST' && pathname === '/api/shops/manual') {
@@ -1595,7 +1802,7 @@ const server = http.createServer((req, res) => {
       rootDir: getWorkspaceRoot(),
       pageSize: config.pageSize,
       delayMs: config.delayMs,
-      cookie: session.cookie,
+      cookieConfigured: !!session.cookie && !!getKsid(),
       port: config.port,
     });
   }
@@ -1682,10 +1889,11 @@ const server = http.createServer((req, res) => {
   }
   if (req.method === 'POST' && pathname === '/api/open-folder') {
     return readBody(req).then((body) => {
+      if (body.root && !isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
       const dir = body.dir || path.join(body.root || getWorkspaceRoot(), body.folderName || '', '_生成图片');
       const fallbackDir = body.dir ? null : path.join(body.root || getWorkspaceRoot(), body.folderName || '');
       const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
-      if (!targetDir) return sendJson(res, 400, { error: '目录不存在: ' + dir });
+      if (!targetDir || !isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir });
       const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
       execFile(command, [targetDir], () => {});
       sendJson(res, 200, { ok: true, opened: targetDir });
@@ -1695,8 +1903,10 @@ const server = http.createServer((req, res) => {
   return serveStatic(pathname, res);
 });
 
-server.listen(config.port, () => {
-  const url = `http://localhost:${config.port}`;
+loadPersistedJobs();
+
+server.listen(config.port, HOST, () => {
+  const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${config.port}`;
   pushSystemLog(`服务已启动:${url}`, 'success');
   console.log(`门店装修工具已启动: ${url}`);
   // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;