diff --git a/.memory/worklog.json b/.memory/worklog.json index ac6040b..27a70e9 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,11 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 23:51 (~1)", - "ts": "2026-05-13T15:53:09Z", - "type": "session-heartbeat" - }, { "files_changed": 2, "hash": "12daaa2", @@ -3250,6 +3244,13 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-15 15:10 (~2)", "files_changed": 4 + }, + { + "ts": "2026-05-15T15:15:47+08:00", + "type": "commit", + "message": "auto-save 2026-05-15 15:15 (~4)", + "hash": "7ee9ea2", + "files_changed": 4 } ] } diff --git a/.project.json b/.project.json index 7c9884f..ad826c7 100644 --- a/.project.json +++ b/.project.json @@ -21,9 +21,9 @@ "type" : "api_key" }, { - "description" : "生产站点 Basic Auth 登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,Nginx 使用 deploy/.htpasswd 哈希文件", - "name" : "WEB_BASIC_AUTH", - "storage" : "/root/skg-marketing-studio-login.txt / deploy/.htpasswd", + "description" : "生产网页登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy/.env.production 的 WEB_AUTH_SESSION_SECRET", + "name" : "WEB_LOGIN", + "storage" : "/root/skg-marketing-studio-login.txt / deploy/.env.production", "type" : "web_login" } ], diff --git a/RULES.md b/RULES.md index 8cd3e48..74048b3 100644 --- a/RULES.md +++ b/RULES.md @@ -14,22 +14,22 @@ ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) -- 发布状态:已部署并验证(2026-05-15);`https://marketing.skg.com` 已启用 Basic Auth,认证后首页 200,`/api/health` 返回 `ok:true` +- 发布状态:已部署并验证(2026-05-15);`https://marketing.skg.com` 已启用应用内登录页,认证后首页 200,`/api/health` 返回 `ok:true` - 主站 / 前端:`https://marketing.skg.com` - API / 后端:`https://marketing.skg.com/api` - 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由) - 管理后台:待定 - 服务器目录:`/opt/skg-marketing-studio` - 生产启动:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build` -- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出并做 Basic Auth,认证后反代 `/api/` 到 `skg-marketing-api:4291`,`api` 容器跑 FastAPI 4291;Traefik 通过 `coolify` 外部网络接入 80/443 +- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443 - 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs` -- 登录凭证:Nginx 使用服务器 `/opt/skg-marketing-studio/deploy/.htpasswd`;明文备份只放服务器 `/root/skg-marketing-studio-login.txt` +- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production` ## 快捷登录 -- 登录地址:`https://marketing.skg.com` +- 登录地址:`https://marketing.skg.com/login/` - 用户名:`skg` - 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库) -- 说明:当前是生产入口 Basic Auth;数据库密码、API Key、服务器 root 密码不要写这里 +- 说明:当前是生产入口应用内登录页;数据库密码、API Key、服务器 root 密码不要写这里 ## 元数据回写清单 - 新增或变更公网地址后,必须同步更新 `.project.json.urls` @@ -50,6 +50,7 @@ - `MINIMAX_TTS_BASE_URL` / `MINIMAX_TTS_MODEL` / `MINIMAX_TTS_VOICE_ID`:MiniMax 配音端点、模型和兜底音色配置 - `MINIMAX_TTS_VOICE_POOL`:MiniMax 英文随机音色池;当前默认男声 `English_magnetic_voiced_man`、女声 `English_Upbeat_Woman`、成熟声 `English_MaturePartner` - `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量 +- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库 - 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库 ## 规则 diff --git a/api/.env.example b/api/.env.example index 7dba4ed..46ca140 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,6 +2,13 @@ LLM_BASE_URL=https://ai.skg.com/ezlink/v1 LLM_API_KEY= +# 应用登录(生产 Nginx auth_request 使用;本地 http 反代测试时 COOKIE_SECURE=false) +WEB_AUTH_USERNAME=skg +WEB_AUTH_PASSWORD= +WEB_AUTH_SESSION_SECRET= +WEB_AUTH_COOKIE_NAME=skg_marketing_session +WEB_AUTH_COOKIE_SECURE=false + # 模型分工 ASR_MODEL=whisper-1 ASR_FALLBACK_MODEL=gemini-2.5-flash diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 08f219b..1a63f48 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -7,6 +7,13 @@ KEYFRAME_COUNT=12 CORS_ORIGINS=https://marketing.skg.com API_PORT=4291 +# Web login. Keep real password and session secret only on the server. +WEB_AUTH_USERNAME=skg +WEB_AUTH_PASSWORD= +WEB_AUTH_SESSION_SECRET= +WEB_AUTH_COOKIE_NAME=skg_marketing_session +WEB_AUTH_COOKIE_SECURE=true + # SKG AI gateway, OpenAI-compatible LLM_BASE_URL=https://ai.skg.com/ezlink/v1 LLM_API_KEY= diff --git a/docs/deploy-vps.md b/docs/deploy-vps.md index 200d686..ce11a3a 100644 --- a/docs/deploy-vps.md +++ b/docs/deploy-vps.md @@ -32,11 +32,14 @@ cp deploy/.env.production.example deploy/.env.production Fill `deploy/.env.production` with the real production keys. Keep this file out of git. -Create the production login file. Replace the username and password as needed: +Create the production web login values in `deploy/.env.production`. Replace the password as needed and keep the session secret private: ```bash -printf 'skg:%s\n' "$(openssl passwd -apr1 'change-this-password')" > deploy/.htpasswd -chmod 644 deploy/.htpasswd +WEB_AUTH_USERNAME=skg +WEB_AUTH_PASSWORD=change-this-password +WEB_AUTH_SESSION_SECRET=$(openssl rand -hex 32) +WEB_AUTH_COOKIE_NAME=skg_marketing_session +WEB_AUTH_COOKIE_SECURE=true ``` Then start: @@ -49,7 +52,8 @@ Verify: ```bash curl -I https://marketing.skg.com -curl https://marketing.skg.com/api/health +curl -I https://marketing.skg.com/login/ +curl -i https://marketing.skg.com/api/health docker compose -f docker-compose.prod.yml ps ``` @@ -64,7 +68,7 @@ docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up - ## Runtime Notes - `web` is a static Next export served by Nginx. -- `web` requires Nginx Basic Auth for the whole site and then proxies `/api/` to `skg-marketing-api:4291`; avoid the generic hostname `api` because the web container also joins the shared Coolify network. +- `web` exposes `/login/` publicly. All workspace routes redirect to `/login/` until the FastAPI session cookie passes Nginx `auth_request`; `/api/` returns JSON 401 when unauthenticated and then proxies to `skg-marketing-api:4291` after login. - `api` is only on the internal project network and stores jobs under `/data/jobs`. - Server-side job files persist in `./data/jobs` on the VPS. - Large uploads are allowed up to `2g` at the Nginx proxy layer. diff --git a/docs/source-analysis.html b/docs/source-analysis.html index a88598c..68856b1 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -536,7 +536,7 @@ 生产站点 https://marketing.skg.com - 公司域名已解析到 VPS 76.13.31.179。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 web 容器用 Nginx 承载静态前端、执行 Basic Auth 登录,并把 /api/ 反代到 FastAPI。 + 公司域名已解析到 VPS 76.13.31.179。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 web 容器用 Nginx 承载静态前端;未登录访问工作台跳转 /login//api/ 通过 auth_request 校验 FastAPI 会话 Cookie 后再反代。 生产部署 @@ -591,6 +591,7 @@ + @@ -605,7 +606,7 @@

后端核心

web/app/page.tsx产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。
web/app/login/page.tsx生产登录页:账号密码表单、保持登录、错误/成功状态,以及参考风格库 14 的四个动画角色互动。
web/components/nodes/index.tsxDAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。
web/components/audio-strip.tsx底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示按原音频时长生成的 SKG 英文产品口播和 MiniMax 随机英文配音。
web/components/lightbox.tsx关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。
- + @@ -806,6 +807,7 @@ SubjectAsset { + @@ -941,14 +943,15 @@ SubjectAsset {
-

2026-05-15 · 生产站点增加登录保护

+

2026-05-15 · 生产站点增加应用内登录页

Runtime Security + UI

问题:公司域名部署后任何人知道地址都能打开工作台并调用生成能力。

-

改动:在生产 web Nginx 容器增加 Basic Auth,整站和 /api/ 统一要求账号密码;哈希文件挂载自服务器 /opt/skg-marketing-studio/deploy/.htpasswd,明文密码只保存在服务器 root 说明文件,不入库。

-

影响:docker-compose.prod.ymldeploy/nginx.conf.gitignore.project.jsonRULES.mddocs/deploy-vps.mddocs/source-analysis.html

+

改动:把浏览器 Basic Auth 改为应用内登录页:前端新增 web/app/login/page.tsx,参考风格库 14 动画角色登录 做四个几何角色、鼠标视线跟随、输入 / 显示密码 / 错误 / 成功状态反馈;后端新增 /auth/login/auth/check/auth/logout,使用 HttpOnly Cookie + HMAC 会话签名;生产 Nginx 通过 auth_request 保护工作台和 /api/

+

影响:api/main.pyweb/app/login/page.tsxweb/app/globals.cssweb/lib/api.tsdocker-compose.prod.ymldeploy/nginx.confdeploy/.env.production.exampleapi/.env.example.project.jsonRULES.mddocs/deploy-vps.mddocs/source-analysis.html

diff --git a/web/app/globals.css b/web/app/globals.css index ea5bbf9..3acc648 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -124,6 +124,245 @@ } } +/* ============================================================ + 生产登录页 · 动画角色登录风格 + ============================================================ */ +.login-page { + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(180deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(135deg, #090a12 0%, #111426 48%, #07080d 100%); + background-size: 44px 44px, 44px 44px, auto; +} +.login-page::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(115deg, transparent 0 18%, rgba(108, 63, 245, 0.16) 18% 28%, transparent 28% 52%, rgba(255, 155, 107, 0.12) 52% 60%, transparent 60%), + linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 28%, rgba(0, 0, 0, 0.36)); +} +.login-page::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px); + background-size: 100% 6px; + opacity: 0.45; + mix-blend-mode: screen; +} +.login-hero { + isolation: isolate; +} +.login-hero::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background: + linear-gradient(145deg, rgba(108, 63, 245, 0.25), transparent 38%), + linear-gradient(20deg, transparent 38%, rgba(45, 45, 45, 0.8) 39% 52%, transparent 53%), + linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 45%); +} +.login-character-stage { + position: relative; + min-height: 320px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(180deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px), + rgba(6, 7, 13, 0.68); + background-size: 34px 34px, 34px 34px, auto; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 30px 70px rgba(0, 0, 0, 0.35); +} +.login-stage-grid { + position: absolute; + inset: 26px; + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 8px; +} +.login-character { + --character-fill: #6c3ff5; + --character-shadow: rgba(108, 63, 245, 0.34); + position: absolute; + width: 128px; + height: 128px; + border: 2px solid rgba(255, 255, 255, 0.22); + background: var(--character-fill); + box-shadow: 0 26px 45px var(--character-shadow), inset 0 14px 24px rgba(255, 255, 255, 0.18); + transition: + transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), + border-radius 0.35s cubic-bezier(0.22, 1, 0.36, 1), + background 0.25s ease; + animation: login-float 5.8s ease-in-out infinite; + animation-delay: calc(var(--i) * -0.55s); +} +.login-character--pilot { + left: 10%; + top: 18%; + border-radius: 34% 54% 46% 42%; +} +.login-character--lens { + --character-fill: #ff9b6b; + --character-shadow: rgba(255, 155, 107, 0.28); + right: 13%; + top: 10%; + width: 112px; + height: 112px; + border-radius: 50%; +} +.login-character--spark { + --character-fill: #e8d754; + --character-shadow: rgba(232, 215, 84, 0.22); + left: 28%; + bottom: 12%; + width: 106px; + height: 106px; + border-radius: 28% 50% 32% 52%; + color: #111; +} +.login-character--keeper { + --character-fill: #2d2d2d; + --character-shadow: rgba(0, 0, 0, 0.5); + right: 28%; + bottom: 16%; + width: 138px; + height: 138px; + border-radius: 46% 38% 54% 34%; +} +.login-character__gloss { + position: absolute; + left: 20%; + top: 16%; + width: 34%; + height: 18%; + border-radius: 999px; + background: rgba(255, 255, 255, 0.22); + transform: rotate(-18deg); +} +.login-character__eye { + position: absolute; + top: 39%; + width: 20px; + height: 20px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.95); + box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.15); +} +.login-character--spark .login-character__eye { + background: rgba(17, 17, 17, 0.92); +} +.login-character__eye::after { + content: ""; + position: absolute; + left: 6px; + top: 6px; + width: 8px; + height: 8px; + border-radius: 999px; + background: #111; + transform: translate(var(--eye-x), var(--eye-y)); + transition: transform 0.08s linear, opacity 0.2s ease; +} +.login-character--spark .login-character__eye::after { + background: #fff; +} +.login-character__eye--left { + left: 28%; +} +.login-character__eye--right { + right: 28%; +} +.login-character__mouth { + position: absolute; + left: 50%; + top: 62%; + width: 30px; + height: 12px; + border-bottom: 3px solid rgba(255, 255, 255, 0.9); + border-radius: 0 0 999px 999px; + transform: translateX(-50%); + transition: transform 0.2s ease, height 0.2s ease, border-radius 0.2s ease; +} +.login-character--spark .login-character__mouth { + border-bottom-color: #111; +} +.login-character__badge { + position: absolute; + right: 15%; + bottom: 14%; + width: 16px; + height: 16px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.35); + transform: rotate(12deg); +} +.login-page[data-mood="typing"] .login-character { + transform: translateY(-8px) scale(1.02); +} +.login-page[data-mood="peek"] .login-character__eye::after { + opacity: 0.25; +} +.login-page[data-mood="peek"] .login-character--keeper { + border-radius: 50% 50% 40% 40%; + transform: translateY(-10px) scale(1.05); +} +.login-page[data-mood="error"] .login-character { + animation: login-shake 0.28s ease-in-out 2; +} +.login-page[data-mood="error"] .login-character__mouth { + height: 14px; + border-top: 3px solid rgba(255, 255, 255, 0.9); + border-bottom: 0; + border-radius: 999px 999px 0 0; + transform: translate(-50%, 5px); +} +.login-page[data-mood="success"] .login-character { + transform: translateY(-14px) scale(1.06); +} +.login-page[data-mood="success"] .login-character__mouth { + height: 18px; +} +@keyframes login-float { + 0%, 100% { translate: 0 0; } + 50% { translate: 0 -12px; } +} +@keyframes login-shake { + 0%, 100% { translate: 0 0; } + 33% { translate: -5px 0; } + 66% { translate: 5px 0; } +} +@media (max-width: 720px) { + .login-character-stage { + min-height: 260px; + } + .login-character { + width: 96px; + height: 96px; + } + .login-character--lens, + .login-character--spark { + width: 84px; + height: 84px; + } + .login-character--keeper { + width: 102px; + height: 102px; + } +} +@media (prefers-reduced-motion: reduce) { + .login-character { + animation: none; + } + .login-page[data-mood="error"] .login-character { + animation: none; + } +} + /* ============================================================ 画布背景:渐变 + 极光 + 颗粒 ============================================================ */ diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx new file mode 100644 index 0000000..762f1a4 --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,253 @@ +"use client" + +import type { CSSProperties, FormEvent } from "react" +import { useEffect, useMemo, useState } from "react" +import { + AlertCircle, + ArrowRight, + CheckCircle2, + Eye, + EyeOff, + LockKeyhole, + ShieldCheck, + Sparkles, + UserRound, +} from "lucide-react" + +type LoginStatus = "idle" | "loading" | "success" +type LoginMood = "idle" | "typing" | "peek" | "error" | "success" + +const CHARACTER_IDS = ["pilot", "lens", "spark", "keeper"] as const + +export default function LoginPage() { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [remember, setRemember] = useState(true) + const [showPassword, setShowPassword] = useState(false) + const [activeField, setActiveField] = useState<"username" | "password" | null>(null) + const [error, setError] = useState("") + const [status, setStatus] = useState("idle") + const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 }) + + useEffect(() => { + const onPointerMove = (event: PointerEvent) => { + const centerX = window.innerWidth / 2 + const centerY = window.innerHeight / 2 + const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX)) + const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY)) + setEyeOffset({ x: nextX * 5, y: nextY * 3 }) + } + window.addEventListener("pointermove", onPointerMove) + return () => window.removeEventListener("pointermove", onPointerMove) + }, []) + + const mood: LoginMood = useMemo(() => { + if (status === "success") return "success" + if (error) return "error" + if (showPassword && activeField === "password") return "peek" + if (activeField || username || password) return "typing" + return "idle" + }, [activeField, error, password, showPassword, status, username]) + + const disabled = status === "loading" || status === "success" + + async function onSubmit(event: FormEvent) { + event.preventDefault() + setError("") + if (!username.trim() || !password) { + setError("请输入账号和密码") + return + } + setStatus("loading") + try { + const res = await fetch("/api/auth/login", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, remember }), + }) + if (!res.ok) { + let message = "账号或密码不正确" + try { + const data = await res.json() + message = data?.detail || data?.error || message + } catch { + // keep default message + } + throw new Error(message) + } + setStatus("success") + window.setTimeout(() => { + window.location.href = "/" + }, 420) + } catch (err) { + setStatus("idle") + setError(err instanceof Error ? err.message : "登录失败,请稍后再试") + } + } + + return ( +
+
+
+
+
+
+
+
+ +
+
+

SKG Marketing Studio

+

营销内容工作台

+
+
+
+ + 生产入口已保护 +
+
+ + +
+ +
+
+
+
+ +
+

登录

+

进入 SKG 营销内容工作台

+
+ +
+ + + +
+ +
+ + marketing.skg.com +
+ +
+ {error ? ( +
+ + {error} +
+ ) : status === "success" ? ( +
+ + 登录成功,正在进入工作台 +
+ ) : null} +
+ + + +
+
+
+
+ ) +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 6ddcfbc..e55c25a 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -398,6 +398,7 @@ export interface Job { export interface BackendHealth { ok: boolean llm_configured: boolean + auth_configured?: boolean base_url: string models?: { asr?: string
api/main.pyFastAPI 单文件后端:状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 英文配音、文件返回。
api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 英文配音、文件返回。
api/product_library/skg-products内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。
jobs/<jobId>/state.json运行时状态文件,不在源码列表里,但刷新恢复依赖它。
jobs/<jobId>/audio.wav拆轨得到的原始音频,底部 Audio Strip 会通过只读接口拉取并在浏览器里解码成波形峰值。
功能接口前端调用说明
网页登录POST /auth/loginGET /auth/checkPOST /auth/logoutweb/app/login/page.tsx、Nginx auth_request登录页提交账号密码到 /api/auth/login,后端设置 HttpOnly 会话 Cookie;生产 Nginx 对工作台和 /api//auth/check 做统一校验,未登录页面跳 /login/,API 返回 JSON 401。
历史列表GET /jobslistJobs所有 job 精简列表(id/url/status/thumbnail/mtime…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填全部历史;带 limit 可截断。
创建任务POST /jobscreateJob提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。
上传视频POST /jobs/uploaduploadJob保存 source.mp4,然后同样进入下载完成状态。