diff --git a/.memory/worklog.json b/.memory/worklog.json
index 174b06d..42ddcf4 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -2545,6 +2545,13 @@
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 23:17 (~2)",
"files_changed": 1
+ },
+ {
+ "ts": "2026-05-13T23:24:03+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-13 23:23 (~1)",
+ "hash": "38091d3",
+ "files_changed": 1
}
]
}
diff --git a/api/main.py b/api/main.py
index 8adc7bf..d6e0451 100644
--- a/api/main.py
+++ b/api/main.py
@@ -850,6 +850,53 @@ def health() -> dict:
}
+class JobSummary(BaseModel):
+ id: str
+ url: str
+ status: JobStatus
+ progress: int = 0
+ message: str = ""
+ duration: float = 0.0
+ width: int = 0
+ height: int = 0
+ video_url: str = ""
+ frame_count: int = 0
+ video_count: int = 0
+ thumbnail: str = ""
+ error: str = ""
+ mtime: float = 0.0
+
+
+@app.get("/jobs", response_model=list[JobSummary])
+def list_jobs(limit: int | None = None) -> list[JobSummary]:
+ """所有 job 的精简列表,按磁盘 state.json mtime 倒序(最新优先)。前端无 ?job= 时用它回填历史。"""
+ items: list[JobSummary] = []
+ for job_id, job in JOBS.items():
+ state_path = JOBS_DIR / job_id / "state.json"
+ mtime = state_path.stat().st_mtime if state_path.exists() else 0.0
+ thumb = f"/jobs/{job_id}/frames/{job.frames[0].index}.jpg" if job.frames else ""
+ items.append(JobSummary(
+ id=job.id,
+ url=job.url,
+ status=job.status,
+ progress=job.progress,
+ message=job.message,
+ duration=job.duration,
+ width=job.width,
+ height=job.height,
+ video_url=job.video_url,
+ frame_count=len(job.frames),
+ video_count=len(job.generated_videos),
+ thumbnail=thumb,
+ error=job.error,
+ mtime=mtime,
+ ))
+ items.sort(key=lambda s: s.mtime, reverse=True)
+ if limit is not None and limit > 0:
+ items = items[:limit]
+ return items
+
+
@app.post("/jobs", response_model=Job)
async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
if not req.url.strip():
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index b3d842a..2ccc55a 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -706,6 +706,7 @@ api/main.py
| 功能 | 接口 | 前端调用 | 说明 |
+ | 历史列表 | GET /jobs | listJobs | 所有 job 精简列表(id/url/status/thumbnail/mtime…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填全部历史;带 limit 可截断。 |
| 创建任务 | POST /jobs | createJob | 提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。 |
| 上传视频 | POST /jobs/upload | uploadJob | 保存 source.mp4,然后同样进入下载完成状态。 |
| 解析视频 | POST /jobs/{id}/analyze | analyzeJob | 拆轨 + 抽关键帧。当前不自动跑 ASR,避免 audio 阻塞视觉管线。 |
@@ -830,6 +831,18 @@ api/main.py
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-13 · 打开应用自动恢复历史 job
+ API
+ Page
+
+
+
问题:前端只从 URL ?job= 读 job 列表,没有任何本地或后端列表回填,打开 / 不带参数就是空白,之前跑过的 job 看不见。
+
改动:后端新增 GET /jobs 列表接口(返回 JobSummary:id/url/status/progress/duration/width/height/video_url/frame_count/video_count/thumbnail/error/mtime,按 state.json mtime 倒序,可带 limit)。前端 page.tsx 启动逻辑:URL 有 ?job= 时尊重 URL;没有时自动调 listJobs() 拿全部历史,反转后让最新 job 落在末尾(active)。持久化基于 api/jobs/<id>/state.json 磁盘文件,不依赖浏览器存储,换浏览器/清缓存都不会傻。
+
影响:api/main.py(JobSummary 类型 + list_jobs endpoint)、web/lib/api.ts(JobSummary + listJobs)、web/app/page.tsx(启动 useEffect)。
+
+
2026-05-13 · 允许骨骼人使用按摩仪后状态变好
diff --git a/web/app/page.tsx b/web/app/page.tsx
index dd1a762..b82b368 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -16,7 +16,7 @@ import { ThemeToggle } from "@/components/theme-toggle"
import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import {
- addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
+ addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
@@ -321,19 +321,29 @@ export default function Home() {
}
}, [job, selectedFrames, setJob])
- // URL ?job=xxx,yyy 自动恢复多个 job
+ // 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
- const idsStr = params.get("job") ?? ""
- const ids = idsStr.split(",").filter(Boolean)
- if (ids.length === 0) return
- Promise.all(ids.map((id) => getJob(id).catch(() => null))).then((results) => {
+ const ids = (params.get("job") ?? "").split(",").filter(Boolean)
+ const restore = async () => {
+ let targetIds = ids
+ if (targetIds.length === 0) {
+ try {
+ const list = await listJobs()
+ targetIds = list.map((s) => s.id).reverse()
+ } catch {
+ return
+ }
+ }
+ if (targetIds.length === 0) return
+ const results = await Promise.all(targetIds.map((id) => getJob(id).catch(() => null)))
const valid = results.filter((j): j is Job => !!j)
if (valid.length > 0) {
setJobs(valid)
setActiveJobId(valid[valid.length - 1].id)
}
- })
+ }
+ void restore()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 26c1513..8c9b2b8 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -220,6 +220,30 @@ export async function getJob(id: string): Promise {
return res.json()
}
+export interface JobSummary {
+ id: string
+ url: string
+ status: JobStatus
+ progress: number
+ message: string
+ duration: number
+ width: number
+ height: number
+ video_url: string
+ frame_count: number
+ video_count: number
+ thumbnail: string
+ error: string
+ mtime: number
+}
+
+export async function listJobs(limit?: number): Promise {
+ const qs = limit && limit > 0 ? `?limit=${limit}` : ""
+ const res = await fetch(`${API_BASE}/jobs${qs}`)
+ if (!res.ok) throw new Error(`listJobs ${res.status}`)
+ return res.json()
+}
+
export async function triggerTranscribe(id: string): Promise {
const res = await fetch(`${API_BASE}/jobs/${id}/transcribe`, { method: "POST" })
if (!res.ok) throw new Error(`transcribe ${res.status}`)