Compare commits

...

40 Commits
main ... dev

Author SHA1 Message Date
zyc
890cb9ab67 chore(core/qa): function-audit toolchain + parity/audit reports + pixel-perfect skill
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
- qa/function-audit: playwright 行为审计工具(audit.mjs/verify-modals.mjs/pages.json)
  + 18 页审计产出(*.audit.md/json、summary、运行日志)
- qa/visual-parity: 调试/测量辅助脚本(_dbg*.mjs/_measure.mjs/_off.mjs)
- core/还原度核对报告.md: 18 页 pixelmatch 核对结果(含 vite 代理陈旧坑记录)
- core/还原与接口待办.md: 逐页还原度/真实数据/交互接入待办总表
- .claude/skills/pixel-perfect-react: 像素级还原 React 的 SKILL 文档
- frontend/public/_devlogin.html: 临时本地登录辅助页(可删)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:41:30 +08:00
zyc
3fac38c5ef feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
消息中心:全量渲染 → 真·后端分页滚动加载
- backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+
  响应回 type_counts(按收件人绝对计数,不受分页/搜索影响)
- frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页;
  代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数
- api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts

命令面板(侧边栏搜索):修复点开后 UI 错位
- app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下;
  补回基类 + header 类名对齐 .shell-command-h
- messages-page.css: 工作台收进视口高度,收件箱在面板内滚动

本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings +
accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:37:41 +08:00
zyc
aa4bdeac83 chore(core/backend): update .env (DB_BIND_ADDRESS + trim CSRF origins)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m49s
- DB_BIND_ADDRESS → 192.168.124.137
- DJANGO_CSRF_TRUSTED_ORIGINS: drop api.airshelf.airlabs.art

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:53:16 +08:00
zyc
0873e724bf feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m51s
Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:46:42 +08:00
zyc
92826dec14 feat(core/backend): pipeline continuity + threaded ffmpeg burn-in export + upload/save-timeline
Video pipeline (script→assets→storyboard→video→stitch):
- robust split_script_into_segments (4 non-empty scenes), scene-aware storyboard/video prompts
- link VideoSegment→ScriptSegment + storyboard-frame reference image (graceful text fallback)
- idempotent poll_video_segment (no double-charge on repeated polling)
- threaded export (no Celery worker needed) + poll-export endpoint
- run_export_job rewritten to filter_complex: per-clip trim, xfade transitions,
  subtitle burn-in (Pillow PNG overlay; this ffmpeg lacks libass), BGM mix
- upload-video-segment / upload-bgm / save-timeline endpoints
- serializers embed asset preview URLs (beat assets pagination); Pillow added to requirements

Also includes prior uncommitted backend work: account preferences/sessions,
billing trend, product/asset endpoints, accounts 0002 migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:46:16 +08:00
seaislee1209
8959946241 fix(core/frontend): settings password-error styling (.pw-err)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:23:56 +08:00
seaislee1209
64b0f3a1aa feat(core/frontend): restore ModelPhotoDemoPage A/B static showcase to baselines (§1 last item)
ModelPhotoDemoPage variant A/B pixel-restored to model-photo-demo-a/b.html (product rail + model grid +
batch result cards / task-stream layout). Sibling AssetFactoryPage/ImageWorkbenchPage untouched.
+ QA: shot-aitools.mjs / shot-pipeline.mjs. verified: tsc --noEmit clean; A/B + model-photo screenshots match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:14:56 +08:00
seaislee1209
2242241c3b feat(core/frontend): ai-tools per-mode layouts (image=chat-stream / model=person picker / cover=platform) + pipeline real unread bell
- ImageWorkbenchPage now renders mode-specific layouts matching each baseline:
  image -> chat-stream (conversation list + hero + prompt chips + chat input bar);
  model -> product rail + 真人模特 cards (assets category=person, fallback Ava/Luna/Mia/Zoe) + per-model count;
  cover -> platform-kit picker. Generation (onGenerate) wiring + loading/empty/fail states preserved.
- pipeline.tsx: bespoke topbar bell now shows real unreadCount (was hardcoded 12); App.tsx threads it.
verified: tsc --noEmit clean; screenshot confirms image-optimize matches chat-stream baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:06:30 +08:00
seaislee1209
579fb7cefa chore(core/frontend): remove dead codex-era exact-* code (§6)
Removed unused codex/iframe-era modules (confirmed no live imports; public/exact/*.html design baselines kept):
- routes/exact-document.tsx, routes/exact-html.ts (~1.6MB generated), routes/exact-dashboard.tsx, routes/exact-pages/ (24 files)
- exact-pages.css + its main.tsx import; index.ts ExactDashboardApp re-export
App.tsx uses the real Dashboard; nothing referenced the removed cluster.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:58:38 +08:00
seaislee1209
099bf0e6aa feat(core/frontend): wire settings/avatar/image-gen + real data render (library/product-detail/pipeline)
App.tsx: thread saveProfile/changePassword/uploadAvatar/generateImages handlers + assets prop to pages.
- settings.tsx: profile save / password modal / avatar upload wired; notification/theme prefs -> localStorage
- library.tsx + product-detail: asset thumbnails + grids render real TOS preview_url
- ai-tools ImageWorkbenchPage: 生成图片 wired to /api/ai/generate-image, renders returned assets
- pipeline.tsx stage2-5: base_assets/storyboard/video_segments(adopted_asset)/timeline(clips/subtitles/bgm)
  rendered from real project data; graceful empty states
- types.ts: +VideoSegment.adopted_asset, +Timeline.subtitle_tracks/bgm_tracks
verified: tsc --noEmit clean; screenshots confirm pipeline stages 2-5 + product-detail render real data+images
(demo asset object_keys re-pointed to image objects so thumbnails resolve)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:20:10 +08:00
seaislee1209
603584b46b feat(core/backend): expose video segment adopted_asset + timeline subtitle/bgm tracks for pipeline rendering
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:01:00 +08:00
seaislee1209
a8f4608d10 feat(core): standalone image-gen endpoint + api.ts methods (profile/password/avatar/generate-image)
- POST /api/ai/generate-image/ — project-less image generation (AITask.project nullable, no schema change),
  reuses VolcanoArk image_generation + credit reserve/charge; modes image/model/cover.
  Verified: manage.py check clean; 2 active IMAGE models present (doubao-seedream-4.5/5.0).
  (Real generation calls Volcano API + charges credit — not yet live-tested to avoid spend.)
- api.ts: updateProfile / changePassword / uploadAvatar / generateImage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:51:36 +08:00
seaislee1209
aad9bd683b feat(core/backend): account settings endpoints (profile PATCH / change password / avatar upload)
§4 settings-save backend (no schema change; User already has phone/avatar_url):
- me/ now GET+PATCH (update name/phone/email)
- POST me/password/ — verify old password, set new (>=8), reissue token
- POST me/avatar/ — multipart -> TOS upload -> presigned avatar_url
Verified: profile PATCH 200, password change round-trip 200, original login restored.
Note: notification/theme prefs have no User storage field -> will persist client-side (no migrate per rules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:41:05 +08:00
seaislee1209
8f80247e0d chore(core): idempotent demo seed (airshelf team) + data screenshot script
- seed_demo.py: ORM-only demo data for airshelf team — 2 products (w/ images+selling points),
  11 assets referencing real TOS objects (thumbnails resolve to real images), one COMPLETED
  project with full pipeline graph (script/base_assets/storyboard/video_segments/timeline) so
  pipeline stage2-5 has real data to render. Idempotent (skips if demo product exists). No schema changes.
- shot-data.mjs: capture dashboard/products/library with seeded data via 127.0.0.1:5180
- verified: products page shows real cover images via TOS preview_url

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:48:13 +08:00
seaislee1209
8bcf7615df feat(core/backend): serve TOS presigned preview_url for asset files
AssetFile.preview_url was stored blank on upload, so all thumbnails fell back to placeholders.
Make preview_url a SerializerMethodField that signs a TOS GET URL from object_key on read
(falls back to stored value, or "" when TOS unconfigured / no key). Verified: presigned URL
for an existing object returns HTTP 200 image/png.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:39:06 +08:00
seaislee1209
78fd7ee13d feat(core/frontend): P1 pixel restoration (settings/messages/wizard/product-create faithful; ai-tools draft)
- settings.tsx + settings-page.css: restore to settings.html (left-nav + sections), real user/team data
- messages.tsx + messages-page.css: rich inbox restore (filters/detail/props grid) on real notifications
- projects.tsx ProjectWizardPage + project-wizard-page.css: restore to projects-new.html
- products.tsx ProductCreateUploadPage + product-create-page.css: restore to product-create-v2 baseline
- ai-tools.tsx (AssetFactory/ImageWorkbench) + ai-tools-page.css: DRAFT unified studio shell;
  deviates from per-page baselines (image-optimize should be chat-stream; model-photo product+person picker)
  -> pending rework alongside P3 standalone image-gen decision
- shot-p1.mjs: playwright visual-parity capture (react vs exact baseline; uses 127.0.0.1 not localhost)
- verified: tsc --noEmit clean; screenshots confirm settings/wizard/product-create/messages faithful

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:25:19 +08:00
seaislee1209
25bf3293df feat(core/frontend): wire P0 team mgmt / recharge / notifications
- App.tsx: load notifications, expose team/recharge/notification handlers, real unread bell count (was hardcoded 12)
- team.tsx: create/edit/reset-password/remove member + team recharge modals
- account.tsx: recharge via wechat/alipay buttons + custom amount
- messages.tsx: real notification inbox, mark-read on select, mark-all-read
- verified: tsc --noEmit clean; e2e create->update->reset->recharge->mark-read->delete all green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:48:51 +08:00
zyc
d41e487f08 feat(k8s): 新增 core 真应用(前端+Django API+Celery worker)构建与部署
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
- core/frontend: Vite 多阶段镜像 + nginx 同源反代 /api,/admin,/static(零 CORS)
- core/backend: Django gunicorn 镜像 + entrypoint(自动 migrate/collectstatic)+ WhiteNoise
- k8s/core: api/worker/web Deployment+Service + ingress(airshelf-web.airlabs.art)
- workflow: 追加 core 前后端 build/push,从 core/backend/.env 套生产覆盖生成 env Secret 后部署
- .gitignore 放行 core/backend/.env;.env 白名单加入 airshelf-web 域名
- 含前端 WIP 还原改动

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:21:41 +08:00
zyc
cfdcd84a30 feat: add AirShelf core implementation 2026-06-05 10:21:40 +08:00
iye
2ba1058329 feat: refine asset media and project setup flows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s
2026-06-01 13:42:52 +08:00
iye
e06a16e200 fix: restore deploy manifests and polish script assistant
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s
2026-05-28 18:56:43 +08:00
iye
df7b90934a Polish navigation and pipeline flow
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 4m37s
2026-05-28 15:35:32 +08:00
iye
bbe29622c2 Polish static UI flows
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 4m39s
2026-05-28 12:29:12 +08:00
iye
5edfa05369 Polish editor timeline interactions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s
2026-05-27 17:59:55 +08:00
iye
134778dde8 替换 Airshelf 品牌 logo
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 9s
2026-05-27 14:52:15 +08:00
iye
54b57f76d0 完善原型 mock 与交互细节
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s
2026-05-27 14:34:16 +08:00
iye
086d92991e 统一 Airshelf 界面组件与图标
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7s
2026-05-27 12:29:41 +08:00
iye
ea335587d2 统一流水线底部按钮尺寸
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7s
2026-05-25 19:29:18 +08:00
iye
553014cc79 更新电商AI平台原型交互
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7s
2026-05-25 19:12:56 +08:00
iye
04335f3269 feat(workbench): 三工序图片区视觉对齐 + 任务中心聚合 + 工具台头部筛选
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7s
- model-photo / platform-cover · 头部 toolbar 落地: 时间 / 模特(平台) chip 下拉 + 折叠搜索
- model-photo / platform-cover · 图片卡片样式同步图片创作 (.io-cell): bg / hover / .gen 脉冲 / .err 红框
- model-photo / platform-cover · 单图 hover overlay: 再次生成 + 下载 + 更多(加入资产库/删除)
- model-photo / platform-cover · 批次底栏: 再次生成图标统一 + 更多 menu(全部加入资产库/删除该批)
- model-photo · 修 TDZ bug: renderModelMini 调用挪到 MODELS 声明后, 解决整页崩溃
- model-photo · 去掉冗余 pv-summary, 商品自动选最近编辑, task 写入 name 字段
- image-optimize · 单图右上加再次生成图标, 加入 fs-image-tasks-image 与任务中心打通
- image-optimize · 输入区拆 3 行: + 在顶 / textarea 满宽 / 发送在底栏右; 参考图缩略与加号同 64×64
- asset-factory · 任务中心加时间 chip + image 类型 + 跳转表; 删冗余类型列
- pipeline · stage2 商品卡换商品库风格 + AI 生成三视图主 CTA + .tri-missing-badge[hidden] CSS 修复
2026-05-22 19:35:36 +08:00
iye
f420af2069 chore: 全量推送 · 累积页面改动 + Next.js 工程骨架 + v1/ 归档 + 文档
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
页面 (电商AI平台/)
- account / team / settings / index / products / projects: 累积迭代
- restraint.css: 设计 token 补充
- login.html / register.html: 新增登录注册页
- _ARCHIVE.md: 归档说明

Next.js 工程骨架
- app/ + components/: 新一代 SPA 雏形 (page / layout / sidebar / topbar / GridBg / Icon)
- package.json / package-lock.json / next.config.mjs / tsconfig.json / postcss.config.mjs / next-env.d.ts

历史归档 / 文档
- v1/: 原 V1 静态稿镜像 (含 mockup-A/B/C)
- PRD.md / deployment-guide.md / _check.html
- ui参考/ / screenshots/

杂项
- .gitignore 调整
- 删除根 README.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:16:46 +08:00
iye
8a783ca36f feat(workbench): 统一立绘详情页参考布局 · 三视图全 16:9 · 工作台批次追加
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
详情页 (pipeline / library / model-photo)
- 统一参考布局:大立绘+缩略 strip+查看大图,右栏 三视图+简介(标签 chip)+3 列属性表
- 底部仅留「下载」+「使用该资产」,去除收藏 / 关闭

三视图固定单张 16:9
- pipeline / library / model-photo / asset-factory / product-studio 全部同步
- 移除原 actor 3 列 3:4 拆图,改为单容器 16:9

图片工作台 (model-photo / platform-cover)
- 立即生成 + 全部重跑 + 单张重跑 均追加新批次到下方,旧批次保留
- 批量按钮下沉到每批次下方,与图片网格左对齐
- hover 重跑/采用 icon 缩小至 26px,右下角横向,无遮罩层
- 立即生成后不再自动新增「编辑中」草稿卡

新建商品 drawer
- 无 onSave 回调时默认跳转 product-detail
- 卖点新增 「+ 添加卖点」按钮(输入框下方独立行,左对齐)

product-detail
- 视频项目卡片状态 pill 改为 4 态(已完成/视频生成 4/6/已归档/故事板失败)
- 移除视频卡个体「通过/不通过/归档」状态切换
- 去掉冗余「通过」status 筛选;过滤逻辑兼容缺失按钮

sidebar (shell.js)
- 图片生成补 badge 12,团队去 badge

清理
- 删除 v2/ 历史镜像目录(与 电商AI平台/ 重复,Dockerfile build context 不依赖)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:12:03 +08:00
UI 设计
2a3ae88dc5 fix(products): 创建商品 · 修复卖点收集(读 .bl-text + 兜底未回车)· 增加必填提示 · 跳转延时 400→1200ms
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5s
2026-05-21 17:23:08 +08:00
UI 设计
e3afa6659b feat(team): banner 右栏空白补「团队动态」卡 · 最近 7 条成员行为流
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5s
2026-05-21 17:15:45 +08:00
UI 设计
8b8a172102 style(team): banner 右边距与下方成员表左列对齐(留出右栏 320+24)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5s
2026-05-21 17:12:15 +08:00
UI 设计
5d09ba72fd fix(team): banner 数值显式 white · 被 restraint.css 全局 .stat .v 黑字覆盖了
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5s
2026-05-21 17:10:51 +08:00
UI 设计
868ba69ea4 fix(cache): 给 shell.js/restraint.css 加 ?v=时间戳 强制绕过浏览器旧缓存
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
2026-05-21 16:44:10 +08:00
UI 设计
704ef9a954 fix(nginx): 去掉 immutable + 缩短缓存 · 迭代期 CSS/JS 改了团队能立刻看到
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 9s
2026-05-21 16:40:11 +08:00
UI 设计
e7c0a14f75 fix(deploy): 把 V2.1 设计稿挪进 电商AI平台/ · Docker 构建上下文是这里
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
2026-05-21 16:30:37 +08:00
UI 设计
e293aa43be feat(v2): 添加 V2.1 设计稿目录 · 团队/设置页 · pipeline 多项 mock 优化
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
2026-05-21 16:18:28 +08:00
483 changed files with 156993 additions and 3914 deletions

View File

@ -0,0 +1,115 @@
---
name: pixel-perfect-react
description: 把 HTML 设计稿像素级精准还原成 React/TSX 组件。当任务涉及"还原页面 / 页面还原度 / HTML 转 React / 设计稿转组件 / exact 页面 React 化 / 精确到 px / 像素对齐 / visual parity / 还原度太粗糙 / 对齐设计稿",或要把 public/exact/*.html 转成 src/routes 下真 React 组件、或修一个已 React 化但还原不准的页面时使用。强制"逐字转写而非重新发挥"+ pixelmatch 像素 diff 闭环,把 diffPixels 逼到趋近 0。
---
# 像素级 HTML → React 还原
> 路径约定:本文路径相对 **AirShelf 仓库根**(即本 `.claude` 的上一级)。前端工程在 `core/frontend/`,设计 SSoT 在 `电商AI平台/design.md`,设计镜像在 `core/frontend/public/exact/`
## 核心原则:转写,不是重画(Transcribe, don't reinterpret)
> **React 文件 = 源 HTML 的忠实转写。只有「数据」和「事件」变,结构 / 类名 / 内联样式 / 文案逐字保留。**
还原度变糙的 **99% 根因**:AI 把 HTML"读懂后重写"成自以为更干净 / 更语义化的结构和类名,于是 `restraint.css` 里靠这些类名和结构生效的精确样式**全部失配**——间距、字号、圆角、颜色就这么一点点漂走了。**禁止重画,只许转写。**
---
## 十条铁律(每条都对应一种"变糙"的根因)
1. **先读设计规范** — 涉及任何样式,先 `Read 电商AI平台/design.md`(§0 协作铁律、§2 token、§8 Don't List)。这是 CLAUDE.md 的硬性要求。
2. **类名逐字保留** — 源 HTML 每个 `class` 原样搬到 `className`。**禁止**改名 / 合并 / 拆分 / 换成 Tailwind utility / 换成内联样式。
3. **DOM 结构 1:1** — 嵌套层级、兄弟顺序、看似多余的空 `<div>`、装饰 `<span class="corner-tr">`/`corner-bl` 全部保留。`restraint.css` 大量用后代选择器和 `:nth-child`,**结构一动样式就错位**。
4. **内联样式精确搬运**`style="width: 33%"``style={{ width: "33%" }}`,数值一个字符都不改;kebab-case → camelCase。**禁止"约等于"**(padding 14 写成 16、宽 33% 写成 35% 都算错)。
5. **文案 / Mono 装饰逐字**`// 05.14``[ 200 OK ]``[ /v1 ]``LIVE`、占位文本全部原样保留,**禁止改写 / 翻译 / 精简 / 补全**。这些是品牌签名。
6. **内联 SVG 属性级保留**`viewBox` / `path d` / `fill` / `stroke-width` 逐字抄,**禁止重画图标或换 lucide 顶替**(除非源 HTML 本就用 lucide)。
7. **加载同一套 CSS + 字体** — React 入口必须 `import` 同一份 `restraint.css` / `styles.css` / `design-restraint.css`;**禁止**在组件里用内联 `<style>` 或新 class 重定义 `.btn` `.pill` `.input` `.modal` `.drawer` 等共享类(design.md 铁律 #3)。要变体回 `restraint.css` 加。
8. **只换数据,不动壳** — 把写死的示例数据替换成 `props` / `state` / `.map()`,**周围 markup 一个标签都不动**。循环渲染时,卡片 / 行内部的完整结构要原样保留。
9. **核对"作用域 CSS 复刻"是否逐行忠实** — 当源页面有页面级内联 `<style>`(登录/向导这类),它往往被复刻进共享 CSS 的一个作用域块(如 `.auth-exact-page .divider {...}`)。**这份复刻本身也可能抄错**:实战见过 `.divider` 被多加 `height:1px; background`,把 OR 分隔塌成一条线——你组件转写得再准也白搭。**逐行 diff 作用域 CSS 块 vs 源 `<style>`,多一行少一行都要揪出来。** 尤其当你"补回一个缺失元素"后样式不对,先怀疑这里,而不是怀疑组件。
10. **警惕"旧全局 CSS 泄漏"** — 同名类(`.balance-banner` `.pay-row` `.recharge-card` `.pane` `.stage-script` `.video-thumb` 等)在共享 styles.css 里常有一份**旧的全局版本**。你新写的 `.page-x .foo` 即使特异性更高,也只覆盖你**显式声明**的属性;你没声明的属性(如 `margin-top`/`max-height`/`border-top`)会**回退到旧全局值**,造成神秘高差、grid `align-items:stretch` 失效、元素被顶偏。**症状**:diff 图里某块以下全部"重影"(垂直错位累积)。**定位**:量 `getBoundingClientRect().height` 对比设计稿镜像,逐元素找多出来的 px;再 `grep` styles.css 找同名旧全局规则。**根治**:确认该旧全局类只此页用(`grep -rl` 组件),直接从 styles.css 删掉那段旧全局规则(你的 scoped 版已自洽);删不干净时在 scoped 规则里显式把泄漏属性清零。**实战:pipeline 删掉 1245-1342 旧全局块,顺手把 stage-1 卡了很久的 pane-h 换行刀刃残差从 25237px→999px——旧全局 `.stage-script`/`.video-thumb max-height` 一直在暗中泄漏。**
---
## 标准流程
### 1. 定位三件套
- **设计源(SSoT)**:`电商AI平台/<page>.html`,或其镜像 `core/frontend/public/exact/<page>.html`
- **共享样式**:`public/exact/assets/restraint.css` + `src/styles.css` + `src/design-restraint.css`
- **目标文件**:`src/routes/<page>.tsx`(接主 SPA 的真组件)
### 2. 截设计稿基线(这是像素目标)
确保前端在跑(`cd core/frontend && npm run dev`),然后在**目标视口**打开设计稿截图:视口 `1440x900``deviceScaleFactor=1``colorScheme: light``reducedMotion: reduce`、等 `document.fonts.ready`。(下面第 5 步的 `compare-page.mjs` 已自动做这些。)
### 3. 逐字转写 HTML → JSX(对照铁律 2-6)
机械转换,别动脑"优化":
- `class``className`,`for``htmlFor`,空元素自闭合(`<br>``<br />`)
- `style="a: x; b: y"``style={{ a: "x", b: "y" }}`,值不变,kebab → camel
- `<!-- 注释 -->``{/* 注释 */}`(可删,但**别碰可见文案**)
- 内联 `onclick` → React handler,**只接交互,不动结构**
- `tabindex``tabIndex`,`stroke-width``strokeWidth` 等 SVG 属性驼峰化
- CSS 自定义属性内联(如 mock-media 的 `--mock-media-url`)→ `style={{ ["--mock-media-url"]: "url(...)" } as CSSProperties}`
### 4. 接数据,保持壳不变
示例数据 → `props` / `state` / `map`。**唯一允许变的就是数据出处和事件**,DOM 一律不动。
### 5. 像素 diff 闭环(仓库已有 pixelmatch 工具,别自己造)
```bash
cd core/qa/visual-parity
node compare-page.mjs \
--source "http://127.0.0.1:5173/exact/<page>.html" \
--target "http://127.0.0.1:5173/< React 路由>" \
--name <page> --viewport 1440x900 --token <登录token>
```
- 读 `output/<page>.report.json``diffPixels` / `diffRatio`
- **打开 `output/<page>.diff.png`:红色高亮处就是 drift**。逐个定位是哪个元素的间距 / 字号 / 字重 / 颜色 / 圆角错了 → 回组件改 → 再 diff。
- **`diffPixels` 每轮必须下降,目标趋近 0。** 上轮没降就是改错了方向。
> ⚠️ **登录态陷阱**:真 React 业务页(`/dashboard` `/products` 等)有登录门禁。`compare-page.mjs --token <token>` 会给 source + target 同时注入登录态。token 取法:`curl -s -X POST http://127.0.0.1:8010/api/auth/login/ -H 'Content-Type: application/json' -d '{"username":"<演示账号>","password":"<密码>"}'`(注意字段是 `username` 不是 email)。源页也要带同一 `?product_id=`/`?project_id=` 等 query,两边同数据才是公平结构 diff。
### 6. 逐项自检(对照 design.md §8 Don't List)
- [ ] 全场 8px 圆角(`>12px` 直接判错;pill / dot `999` 例外)
- [ ] 全场只有**一个**橙色 accent;hover 用 alpha 不换 hue
- [ ] 无裸 hex,颜色全部用 design.md §2.1 的 token
- [ ] 字重只有 400 / 500 / 600(700 仅 Ctrl K 徽标)
- [ ] 用 inside-border(`box-shadow: inset`)而非真 `border`(hover 不抖)
- [ ] Mono 装饰在位(`// xx` `[ 200 OK ]`)
- [ ] 只有主 CTA 有阴影,其他场景无阴影
- [ ] 没动基础 token(`--heat` `--background-base` `--border-faint`)
---
## 实战补遗(2026-06 全站还原沉淀 · 比铁律更具体的坑)
> 这套页面的"真实感"不是单层数据——**镜像视觉 = 三层叠加,要像素对齐必须三层都复刻**。诊断时**先用 Playwright 把镜像(带 `?id` + token)的真实 DOM 抓出来**(可见元素数 / 计数 / 每个 thumb 的 computed backgroundImage / 步进器 dot 类名),照着复刻,比盲读 HTML 快得多。
1. **三层叠加**:① `public/exact/assets/api-bridge.js``renderPageX` 只 hydrate 少量真字段(且 `setField` 仅在值非空时覆盖,否则留 mock 默认值——React 必须照抄"真值‖mock默认"回退);② 页面自带内联 `<script>` 跑默认筛选/排序(如 product-detail 素材默认 status filter「通过」永远生效→只显示 pass 卡而非全部);③ `shell.js` 加载 `assets/mock-media.js`,对所有 `.placeholder` 按"上下文文本"正则塞 mock 图。
2. **mock-media 映射**:`.placeholder` 命中即加 `.has-mock-media` 类(共享 CSS 里该类 `background-image:var(--mock-media-url)` + 把 `.ph-frame` 透明)。React 要给对应元素手动加 `has-mock-media` + 内联 `--mock-media-url`。映射:面膜/补水/玻尿酸→product-mask;平台/套图→scene-tabletop;办公→scene-office;床头/卧室→scene-bedroom;林夕/主播/女性→person-linxi;视频封面 mask→cover-mask-v3 / final→cover-mask-final。**特例**:`#ed-canvas` 这类非 `.placeholder` 元素,mock-media 只设内联 `backgroundImage`(size 默认 auto 平铺,**别加 cover**)。
3. **运行时会改静态 HTML——别照抄静态**:products 静态 result-meta 里有 grid/list 切换器,但镜像 api-bridge 运行时把它删了(只剩计数 span);照静态加上去会让该行变高、把整块网格下推几 px → 全红。**以 Playwright 抓到的实时 DOM 为准,不是静态文件。**
4. **默认 tab/pane 对齐镜像而非"全部"**:library 镜像默认 active=人物(0 资产→空态),不是"全部";pipeline 默认可见 pane=脚本(stage1),但步进器 active=项目真实阶段(二者解耦)。
5. **步进器(pipeline)严格复刻 `activateStage`**:`activeDot = 已导航?所看阶段:项目阶段`;`completed=max(项目阶段-1, activeDot-1)`;dot `i===activeDot active / i<=completed done`;line `n<=completed done`
6. **chip caret 别自己写宽高**:给 `.chip .caret``width/margin` 会让每个 chip 宽 +2px、多个 chip 累计错位文字重影。交给全局 restraint `.chip` 管。(projects 这一删 1494→182px)
7. **作用域写法**:用 CSS 嵌套 `.xxx-page { ... }`,`@keyframes` 提到顶层。`.app.xxx-page` 全屏页用 `& > main` 子选择器。共享类(tabs/chip/toolbar/search-inline/result-meta/empty-filter/pill/btn/.stat/.prog)走全局 design-restraint(它是 restraint.css 的端口,在 styles.css 之后加载→覆盖旧全局),页面专属类才 scope。
---
## 反模式(出现任一即判"变糙",必须重做)
- ❌ 把 HTML 结构"优化"成更少的 `div` / 更语义化的标签
- ❌ 自创 class 名,或用 Tailwind / 内联样式替代 `restraint` 共享类
- ❌ 内联样式凭感觉约等于(`padding 14 → 16``width 33% → 35%`)
- ❌ 删改 Mono 装饰 / 占位文案 / 标点
- ❌ 重画 SVG 图标,或拿相近图标顶替
- ❌ 在组件里 `<style>` 重定义共享类
- ❌ 不截图不 diff,肉眼"差不多"就提交
- ❌ 改了基础 token,影响全站
- ❌ 照抄静态 HTML 的运行时被改部分(view-tog、默认 tab、注入的 mock 图)
---
## 验收门槛
`diffPixels` 逐轮下降且 `diff.png` 无业务性红块为通过。字体抗锯齿 + 共享 shell 残差(crumb mock 名 / nav 徽标 / bell 计数)等**非业务**差异允许残留,但必须在 report 旁单独记一行说明。视频真实生成不在还原阶段触发。
## 一页话总结
**读 design.md → 截设计稿基线 → 逐字转写(类名/结构/内联样式/文案/SVG 全保真)→ 只换数据(三层叠加都复刻)→ pixelmatch diff 到趋近 0 → 对 §8 自检。** 任何"我觉得这样更好"的改写,都是还原度变糙的源头。

View File

@ -27,6 +27,7 @@ jobs:
echo "CR_ORG=prod" >> $GITHUB_ENV echo "CR_ORG=prod" >> $GITHUB_ENV
echo "DEPLOY_ENV=production" >> $GITHUB_ENV echo "DEPLOY_ENV=production" >> $GITHUB_ENV
echo "DOMAIN_WEB=airshelf.airlabs.art" >> $GITHUB_ENV echo "DOMAIN_WEB=airshelf.airlabs.art" >> $GITHUB_ENV
echo "DOMAIN_CORE=airshelf-web.airlabs.art" >> $GITHUB_ENV
elif [[ "${{ github.ref_name }}" == "dev" ]]; then elif [[ "${{ github.ref_name }}" == "dev" ]]; then
echo "IMAGE_TAG=dev-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV echo "IMAGE_TAG=dev-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV
@ -35,6 +36,7 @@ jobs:
echo "CR_ORG=dev" >> $GITHUB_ENV echo "CR_ORG=dev" >> $GITHUB_ENV
echo "DEPLOY_ENV=development" >> $GITHUB_ENV echo "DEPLOY_ENV=development" >> $GITHUB_ENV
echo "DOMAIN_WEB=airshelf.test.airlabs.art" >> $GITHUB_ENV echo "DOMAIN_WEB=airshelf.test.airlabs.art" >> $GITHUB_ENV
echo "DOMAIN_CORE=airshelf-web.test.airlabs.art" >> $GITHUB_ENV
fi fi
- name: Login to Volcano Engine CR - name: Login to Volcano Engine CR
@ -63,6 +65,50 @@ jobs:
done done
[ $ok -eq 1 ] || { echo "ERROR: web push failed after 3 attempts"; exit 1; } [ $ok -eq 1 ] || { echo "ERROR: web push failed after 3 attempts"; exit 1; }
- name: Build and Push Core API (Django)
id: build_core_api
run: |
set -o pipefail
ok=0
for attempt in 1 2 3; do
echo "Build core-api attempt $attempt/3..."
DOCKER_BUILDKIT=0 docker build \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:${{ env.IMAGE_TAG }} \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:latest \
"./core/backend" 2>&1 | tee /tmp/build-core-api.log && { ok=1; break; }
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
done
[ $ok -eq 1 ] || { echo "ERROR: core-api build failed after 3 attempts"; exit 1; }
ok=0
for attempt in 1 2 3; do
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:${{ env.IMAGE_TAG }} && \
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:latest && { ok=1; break; }
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
done
[ $ok -eq 1 ] || { echo "ERROR: core-api push failed after 3 attempts"; exit 1; }
- name: Build and Push Core Web (React/Vite)
id: build_core_web
run: |
set -o pipefail
ok=0
for attempt in 1 2 3; do
echo "Build core-web attempt $attempt/3..."
DOCKER_BUILDKIT=0 docker build \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:${{ env.IMAGE_TAG }} \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:latest \
"./core/frontend" 2>&1 | tee /tmp/build-core-web.log && { ok=1; break; }
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
done
[ $ok -eq 1 ] || { echo "ERROR: core-web build failed after 3 attempts"; exit 1; }
ok=0
for attempt in 1 2 3; do
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:${{ env.IMAGE_TAG }} && \
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:latest && { ok=1; break; }
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
done
[ $ok -eq 1 ] || { echo "ERROR: core-web push failed after 3 attempts"; exit 1; }
- name: Setup Kubectl - name: Setup Kubectl
run: | run: |
if ! command -v kubectl &>/dev/null; then if ! command -v kubectl &>/dev/null; then
@ -94,12 +140,32 @@ jobs:
echo "Environment: ${{ env.DEPLOY_ENV }}" echo "Environment: ${{ env.DEPLOY_ENV }}"
CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}" CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}"
# Replace image placeholders # Replace image placeholders (PRD design site)
sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-web:latest|${CR_IMAGE}/airshelf-web:${{ env.IMAGE_TAG }}|g" k8s/web-deployment.yaml sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-web:latest|${CR_IMAGE}/airshelf-web:${{ env.IMAGE_TAG }}|g" k8s/web-deployment.yaml
# Replace domain placeholder in ingress # Replace domain placeholder in ingress
sed -i "s|airshelf.airlabs.art|${{ env.DOMAIN_WEB }}|g" k8s/ingress.yaml sed -i "s|airshelf.airlabs.art|${{ env.DOMAIN_WEB }}|g" k8s/ingress.yaml
# ===== Core (real app) image + domain substitution =====
sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-core-api:latest|${CR_IMAGE}/airshelf-core-api:${{ env.IMAGE_TAG }}|g" k8s/core/api-deployment.yaml k8s/core/worker-deployment.yaml
sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-core-web:latest|${CR_IMAGE}/airshelf-core-web:${{ env.IMAGE_TAG }}|g" k8s/core/web-deployment.yaml
sed -i "s|airshelf-web.airlabs.art|${{ env.DOMAIN_CORE }}|g" k8s/core/ingress.yaml
# ===== Build core env file: core/backend/.env + production overrides =====
# Source of truth is core/backend/.env (committed; real MySQL + managed Redis + TOS + ARK).
# Override only the env-specific bits; DB_BIND_ADDRESS is dropped (dev LAN IP
# has no NIC in-cluster), settings -> production, hosts/CSRF/CORS -> the domain.
grep -vE '^\s*(#|$)' core/backend/.env \
| grep -vE '^(DJANGO_SETTINGS_MODULE|DJANGO_DEBUG|DB_BIND_ADDRESS|DJANGO_ALLOWED_HOSTS|DJANGO_CSRF_TRUSTED_ORIGINS|CORS_ALLOWED_ORIGINS)=' \
> /tmp/core.env
{
echo "DJANGO_SETTINGS_MODULE=airshelf.settings.production"
echo "DJANGO_DEBUG=false"
echo "DJANGO_ALLOWED_HOSTS=airshelf-web.airlabs.art,${{ env.DOMAIN_CORE }},localhost,127.0.0.1"
echo "DJANGO_CSRF_TRUSTED_ORIGINS=https://airshelf-web.airlabs.art,https://${{ env.DOMAIN_CORE }}"
echo "CORS_ALLOWED_ORIGINS=https://airshelf-web.airlabs.art,https://${{ env.DOMAIN_CORE }}"
} >> /tmp/core.env
# All kubectl operations with retry (K3s 内网连接可能抖动) # All kubectl operations with retry (K3s 内网连接可能抖动)
export KUBECTL_TIMEOUT="--request-timeout=4s" export KUBECTL_TIMEOUT="--request-timeout=4s"
@ -114,16 +180,32 @@ jobs:
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \ --docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
--dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f - --dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f -
# Apply manifests # Core backend env secret (real MySQL / managed Redis / TOS / ARK)
kubectl $KUBECTL_TIMEOUT create secret generic airshelf-core-env \
--from-env-file=/tmp/core.env \
--dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f -
# Apply manifests — shared infra
kubectl $KUBECTL_TIMEOUT apply -f k8s/cert-manager-issuer.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/cert-manager-issuer.yaml
kubectl $KUBECTL_TIMEOUT apply -f k8s/redirect-https-middleware.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/redirect-https-middleware.yaml
# PRD design site
kubectl $KUBECTL_TIMEOUT apply -f k8s/web-deployment.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/web-deployment.yaml
kubectl $KUBECTL_TIMEOUT apply -f k8s/ingress.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/ingress.yaml
# Core real app (api + celery worker + web + ingress)
kubectl $KUBECTL_TIMEOUT apply -f k8s/core/api-deployment.yaml
kubectl $KUBECTL_TIMEOUT apply -f k8s/core/worker-deployment.yaml
kubectl $KUBECTL_TIMEOUT apply -f k8s/core/web-deployment.yaml
kubectl $KUBECTL_TIMEOUT apply -f k8s/core/ingress.yaml
# Preserve real client IP # Preserve real client IP
kubectl $KUBECTL_TIMEOUT patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true kubectl $KUBECTL_TIMEOUT patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true
kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-web kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-web
kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-api
kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-worker
kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-web
} 2>&1 | tee /tmp/deploy.log && { ok=1; break; } } 2>&1 | tee /tmp/deploy.log && { ok=1; break; }
echo "Attempt $attempt failed, retrying in 30s..." echo "Attempt $attempt failed, retrying in 30s..."
sleep 30 sleep 30
@ -143,6 +225,16 @@ jobs:
if [ -f /tmp/build.log ]; then if [ -f /tmp/build.log ]; then
BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
fi fi
elif [[ "${{ steps.build_core_api.outcome }}" == "failure" ]]; then
FAILED_STEP="build"
if [ -f /tmp/build-core-api.log ]; then
BUILD_LOG=$(tail -50 /tmp/build-core-api.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
fi
elif [[ "${{ steps.build_core_web.outcome }}" == "failure" ]]; then
FAILED_STEP="build"
if [ -f /tmp/build-core-web.log ]; then
BUILD_LOG=$(tail -50 /tmp/build-core-web.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
fi
elif [[ "${{ steps.deploy.outcome }}" == "failure" ]]; then elif [[ "${{ steps.deploy.outcome }}" == "failure" ]]; then
FAILED_STEP="deploy" FAILED_STEP="deploy"
if [ -f /tmp/deploy.log ]; then if [ -f /tmp/deploy.log ]; then

47
.gitignore vendored
View File

@ -1,28 +1,21 @@
# 工程文件 node_modules
node_modules/ .next
.next/ out
.turbo/ dist
dist/ .env*.local
build/
# OS / IDE
.DS_Store
Thumbs.db
.vscode/
.idea/
*.swp
# 日志
*.log
npm-debug.log*
pnpm-debug.log*
# 本地环境
.env .env
.env.local # core 后端环境变量需要进 CI 构建,放行它(其余 .env 仍忽略)
.env.*.local !core/backend/.env
.venv
# 临时 __pycache__/
*.tmp *.pyc
*.bak db.sqlite3
screenshots/ .playwright-cli
account.md
core/frontend/output/
core/qa/*.png
core/qa/visual-parity/output/
.DS_Store
*.tsbuildinfo
next-env.d.ts.bak
_design_src

106
AGENTS.md Normal file
View File

@ -0,0 +1,106 @@
# Airshelf · 电商 AI 平台 · Codex 工程约定
> **本文件由 Codex 启动时自动加载。所有 AI 协作必须遵循以下规则。**
---
## 项目简介
**Airshelf** · AI 短视频带货生成平台 · 5 阶段流水线(商品 → 故事板 → 镜头 → 生成 → 投放)
- **设计代号:** Restraint · V2.1 · Firecrawl-aligned
- **主要工作目录:** [电商AI平台/](电商AI平台/)
- **Next.js 工程(独立):** [app/](app/)
- **V1 历史归档:** [v1/](v1/)
- **V2.1 归档(原 v2.1/):** [v2/](v2/)
---
## ★ 设计规范铁律(每次涉及页面 / CSS / UI 必读)
### 触发条件
**只要任务涉及以下任一种,必须先 Read [电商AI平台/design.md](电商AI平台/design.md):**
- 修改 `.html` 文件
- 修改 `assets/restraint.css` 或任何 `.css`
- 修改 inline `<style>`
- 添加新页面 / 新组件
- 调整布局 / 间距 / 颜色 / 字号
- 用户提到"页面" "样式" "视觉" "组件" "色" "字" "圆角" "间距" 等关键词
### 必读章节
- [design.md §0 AI 协作铁律](电商AI平台/design.md#0--ai-协作铁律每次启动必读) — 必读
- [design.md §1 设计哲学](电商AI平台/design.md#1--设计哲学) — 价值观
- [design.md §3 组件清单](电商AI平台/design.md#4--组件清单restraintcss-已实现--不要重发明) — 用现成组件
- [design.md §8 Don't List](电商AI平台/design.md#8--dont-list绝对禁止--每次自检) — 自检
### 7 条铁律
1. **任何页面 / CSS 调整前必须 Read [电商AI平台/design.md](电商AI平台/design.md)** — 不读不动手
2. **检查 [电商AI平台/assets/restraint.css](电商AI平台/assets/restraint.css) 已有组件**`Grep ".btn|.pill|.input"`
3. **禁止在页面 inline `<style>` 重写共享类**(`.btn` `.pill` `.input` `.modal` `.drawer` `.toast` `.field` `.tabs` `.chip` `.stats` `.list-row` 等)— 要变体回 restraint.css 加
4. **禁止创建新色值** — 必须用 design.md §2.1 的 token,不写裸 hex
5. **禁止改动基础 token**(`--heat` `--background-base` `--border-faint` 等)— 改了破坏全站
6. **完成后对照 [design.md §8 Don't List](电商AI平台/design.md#8--dont-list绝对禁止--每次自检) 逐条自检**
7. **不确定就问用户**,不要凭感觉发挥 — 用户原话:"我都希望你能遵循我们的设计规范,而不是乱做"
---
## 设计核心速记(详见 design.md)
- **冷灰底** `#f9f9f9` · 主橙 `#fa5d19` · 主前景 `#262626`
- **全场 8 px 圆角**(Pill / dot 999 例外)· `>12 px` 直接判错
- **inside-border** 而非真 `border`(hover 不抖动)
- **单橙锚点** · 全场只有一个 accent · hover 用 alpha 不用换 hue
- **Mono 装饰必有** · `[ 200 OK ]` `// 05.14` `[ /v2 ]`(品牌签名)
- **主 CTA 唯一允许阴影** · 4 层橙色发光 · 其他场景禁阴影
- **Inter(英/数字/装饰)+ Alibaba PuHuiTi(中)** · 字符级 fallthrough
- **字重仅 3 档** · 400 / 500 / 600 · 700 仅给 Ctrl K 徽标
---
## Git 工作流
- **当前开发分支:** `dev`
- **主分支:** `main` (生产)
- **严禁直推 master/main** — 走 dev 分支 → PR → 合并触发 CI/CD
- **严禁 `--no-verify` 跳过 hook**
- **Push 规则:** 默认不 push,改完即停 · 用户明确说"push / 推一下"才执行
- **commit 前不要 amend** — 创建新 commit,避免破坏历史
## 文件操作
- **三视图 = 单张 16:9 图** · 不要拆成 3 张缩略 · 用 `aspect-ratio: 16/9` 单容器
- **设计稿优先** · 写代码前必须先读 [电商AI平台/_design_src/](电商AI平台/_design_src/) 设计稿(如果有)
- **`.pen` 文件加密** · 只能用 pencil MCP 工具,不能 Read/Grep
## 本机连接备忘
- 火山 MySQL 公网域名 `mysql-8351f937d637-public.rds.volces.com` 在本机可能被 TUN / 代理解析到 `198.18.x.x` fake-ip,导致 MySQL 握手阶段断开。
- 本机开发连接测试 MySQL 时,优先使用真实公网 IP `14.103.27.192`,并加 `--bind-address=192.168.124.86`
- 部署到火山内网 / K8s 时,优先使用私网地址 `mysql8351f937d637.rds.ivolces.com`
- 账号、密码、ARK/TOS/Redis 等敏感信息记录在 [account.md](account.md),不要复制到本文件。
---
## 用户偏好
- **角色:** UI 设计师 · 不读代码报错,只看最终视觉结果
- **不需要的:** 终端报错截图、深奥的代码解释、过度的实施细节
- **需要的:** 简短状态更新、视觉结果对照、清晰的"对/错"反馈
---
## 关键路径速查
| 资产 | 路径 |
| ---- | ---- |
| **设计规范(SSoT)** | [电商AI平台/design.md](电商AI平台/design.md) |
| **共享 CSS** | [电商AI平台/assets/restraint.css](电商AI平台/assets/restraint.css) |
| **Shell 注入** | [电商AI平台/assets/shell.js](电商AI平台/assets/shell.js) |
| **视觉样板间(归档)** | [电商AI平台/_archive/design-system.html](电商AI平台/_archive/design-system.html) |
| **规范理论(归档)** | [电商AI平台/_archive/DESIGN_SPEC_V2.md](电商AI平台/_archive/DESIGN_SPEC_V2.md) |
| **设计稿源** | [电商AI平台/_design_src/](电商AI平台/_design_src/) |
---
**违反任何规范规则,用户有权要求重做,无需解释。**

99
CLAUDE.md Normal file
View File

@ -0,0 +1,99 @@
# Airshelf · 电商 AI 平台 · Claude Code 工程约定
> **本文件由 Claude Code 启动时自动加载。所有 AI 协作必须遵循以下规则。**
---
## 项目简介
**Airshelf** · AI 短视频带货生成平台 · 5 阶段流水线(商品 → 故事板 → 镜头 → 生成 → 投放)
- **设计代号:** Restraint · V2.1 · Firecrawl-aligned
- **主要工作目录:** [电商AI平台/](电商AI平台/)
- **Next.js 工程(独立):** [app/](app/)
- **V1 历史归档:** [v1/](v1/)
- **V2.1 归档(原 v2.1/):** [v2/](v2/)
---
## ★ 设计规范铁律(每次涉及页面 / CSS / UI 必读)
### 触发条件
**只要任务涉及以下任一种,必须先 Read [电商AI平台/design.md](电商AI平台/design.md):**
- 修改 `.html` 文件
- 修改 `assets/restraint.css` 或任何 `.css`
- 修改 inline `<style>`
- 添加新页面 / 新组件
- 调整布局 / 间距 / 颜色 / 字号
- 用户提到"页面" "样式" "视觉" "组件" "色" "字" "圆角" "间距" 等关键词
### 必读章节
- [design.md §0 AI 协作铁律](电商AI平台/design.md#0--ai-协作铁律每次启动必读) — 必读
- [design.md §1 设计哲学](电商AI平台/design.md#1--设计哲学) — 价值观
- [design.md §3 组件清单](电商AI平台/design.md#4--组件清单restraintcss-已实现--不要重发明) — 用现成组件
- [design.md §8 Don't List](电商AI平台/design.md#8--dont-list绝对禁止--每次自检) — 自检
### 7 条铁律
1. **任何页面 / CSS 调整前必须 Read [电商AI平台/design.md](电商AI平台/design.md)** — 不读不动手
2. **检查 [电商AI平台/assets/restraint.css](电商AI平台/assets/restraint.css) 已有组件**`Grep ".btn|.pill|.input"`
3. **禁止在页面 inline `<style>` 重写共享类**(`.btn` `.pill` `.input` `.modal` `.drawer` `.toast` `.field` `.tabs` `.chip` `.stats` `.list-row` 等)— 要变体回 restraint.css 加
4. **禁止创建新色值** — 必须用 design.md §2.1 的 token,不写裸 hex
5. **禁止改动基础 token**(`--heat` `--background-base` `--border-faint` 等)— 改了破坏全站
6. **完成后对照 [design.md §8 Don't List](电商AI平台/design.md#8--dont-list绝对禁止--每次自检) 逐条自检**
7. **不确定就问用户**,不要凭感觉发挥 — 用户原话:"我都希望你能遵循我们的设计规范,而不是乱做"
---
## 设计核心速记(详见 design.md)
- **冷灰底** `#f9f9f9` · 主橙 `#fa5d19` · 主前景 `#262626`
- **全场 8 px 圆角**(Pill / dot 999 例外)· `>12 px` 直接判错
- **inside-border** 而非真 `border`(hover 不抖动)
- **单橙锚点** · 全场只有一个 accent · hover 用 alpha 不用换 hue
- **Mono 装饰必有** · `[ 200 OK ]` `// 05.14` `[ /v2 ]`(品牌签名)
- **主 CTA 唯一允许阴影** · 4 层橙色发光 · 其他场景禁阴影
- **Inter(英/数字/装饰)+ Alibaba PuHuiTi(中)** · 字符级 fallthrough
- **字重仅 3 档** · 400 / 500 / 600 · 700 仅给 Ctrl K 徽标
---
## Git 工作流
- **当前开发分支:** `dev`
- **主分支:** `main` (生产)
- **严禁直推 master/main** — 走 dev 分支 → PR → 合并触发 CI/CD
- **严禁 `--no-verify` 跳过 hook**
- **Push 规则:** 默认不 push,改完即停 · 用户明确说"push / 推一下"才执行
- **commit 前不要 amend** — 创建新 commit,避免破坏历史
## 文件操作
- **三视图 = 单张 16:9 图** · 不要拆成 3 张缩略 · 用 `aspect-ratio: 16/9` 单容器
- **设计稿优先** · 写代码前必须先读 [电商AI平台/_design_src/](电商AI平台/_design_src/) 设计稿(如果有)
- **`.pen` 文件加密** · 只能用 pencil MCP 工具,不能 Read/Grep
---
## 用户偏好
- **角色:** UI 设计师 · 不读代码报错,只看最终视觉结果
- **不需要的:** 终端报错截图、深奥的代码解释、过度的实施细节
- **需要的:** 简短状态更新、视觉结果对照、清晰的"对/错"反馈
---
## 关键路径速查
| 资产 | 路径 |
| ---- | ---- |
| **设计规范(SSoT)** | [电商AI平台/design.md](电商AI平台/design.md) |
| **共享 CSS** | [电商AI平台/assets/restraint.css](电商AI平台/assets/restraint.css) |
| **Shell 注入** | [电商AI平台/assets/shell.js](电商AI平台/assets/shell.js) |
| **视觉样板间(归档)** | [电商AI平台/_archive/design-system.html](电商AI平台/_archive/design-system.html) |
| **规范理论(归档)** | [电商AI平台/_archive/DESIGN_SPEC_V2.md](电商AI平台/_archive/DESIGN_SPEC_V2.md) |
| **设计稿源** | [电商AI平台/_design_src/](电商AI平台/_design_src/) |
---
**违反任何规范规则,用户有权要求重做,无需解释。**

1150
PRD.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +0,0 @@
# AirShelf · UI 设计稿货架
@zyc / iye 的 UI 设计稿合集。每个子目录是一个独立项目,各自有自己的设计规范和静态稿。
## 货架内容
| 项目 | 风格 | 说明 |
| --- | --- | --- |
| [电商AI平台/](电商AI平台/) | Restraint V2.1 (Firecrawl-aligned) | AI 短视频带货生成平台 · 10 个页面 · 完整 5 阶段流水线 |
---
## 浏览方式
直接 clone + 用浏览器打开任意 `*.html`:
```bash
git clone https://gitea.airlabs.art/zyc/AirShelf.git
cd AirShelf/电商AI平台
# 浏览器直开 index.html · 或本地起 server
npx http-server . -p 8080
```
## 添加新项目
新项目作为根目录下的兄弟文件夹(中文命名 OK),保持各自独立:
```
AirShelf/
├── 电商AI平台/
├── <未来项目 B>/
└── <未来项目 C>/
```
---
## 部署
CI/CD 走 Gitea Actions + 火山引擎 CR + K3s(traefik + cert-manager)。
| 分支 | 环境 | 域名 | Image tag |
| --- | --- | --- | --- |
| `master` | production | `airshelf.airlabs.art` | `prod-YYYYMMDD-<sha7>` |
| `dev` | development | `airshelf.test.airlabs.art` | `dev-YYYYMMDD-<sha7>` |
推到对应分支会自动触发 [.gitea/workflows/deploy.yaml](.gitea/workflows/deploy.yaml):
checkout → docker build/push (`airshelf-web`,无构建阶段、纯 nginx + 静态) → kubectl apply [k8s/](k8s/) → rollout restart。
构建上下文是 `电商AI平台/`,Dockerfile/nginx.conf 都在该子目录。当前仅一个项目,故 image 名固定 `airshelf-web`;若未来加兄弟项目,流水线需要扩展为按项目分别构建。
**Gitea 仓库需要配置的 Secrets:**
- prod: `CR_PROD_PASSWORD` · `VOLCANO_PROD_KUBE_CONFIG`
- dev: `CR_SERVER` · `CR_USERNAME` · `CR_PASSWORD` · `VOLCANO_TEST_KUBE_CONFIG`

View File

@ -0,0 +1,21 @@
# Root Next.js Archive · 2026-05-28
This folder preserves the earlier root-level Next.js scaffold.
It was archived because the current product surface is the static HTML app in
`电商AI平台/*.html`, while this Next.js implementation only contained a partial
route set and could confuse future edits.
Archived paths:
- `app/`
- `components/`
- `k8s/`
- `package.json`
- `package-lock.json`
- `next.config.mjs`
- `next-env.d.ts`
- `postcss.config.mjs`
- `tsconfig.json`
- `deployment-guide.md`
To restore it later, move these files back to the repository root.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import GridBg from "@/components/GridBg";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
export const metadata: Metadata = {
title: "Airshelf — AI 短视频生产平台",
description:
"为抖音 / TikTok 商户打造的 AI 短视频生产流水线 · 脚本 → 基础资产 → 故事板 → 视频片段 → 拼接导出",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className={inter.variable}>
<body>
<div className="app">
<Sidebar />
<main className="main">
<GridBg />
{children}
</main>
</div>
</body>
</html>
);
}

View File

@ -0,0 +1,181 @@
import Link from "next/link";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
interface Recent {
name: string;
meta: string;
prog: ("done" | "cur" | "fail" | "")[];
pill: { kind: "info" | "ok" | "err"; label: string };
action: { label: string; href: string };
}
const RECENT: Recent[] = [
{
name: "补水面膜 · 痛点种草",
meta: "补水面膜 / AI 全生 / 6 镜",
prog: ["done", "done", "cur", "", ""],
pill: { kind: "info", label: "故事板 待确认" },
action: { label: "继续", href: "/pipeline?stage=3" },
},
{
name: "蓝牙耳机 · 开箱测评",
meta: "南卡 Lite Pro / 自带脚本 / 5 镜",
prog: ["done", "done", "done", "done", "done"],
pill: { kind: "ok", label: "已完成" },
action: { label: "打开", href: "/pipeline?stage=5" },
},
{
name: "速食牛肉面 · 一句话主题",
meta: "滋啦速食 / 一句话 / 4 镜",
prog: ["done", "cur", "", "", ""],
pill: { kind: "info", label: "资产生成中" },
action: { label: "继续", href: "/pipeline?stage=2" },
},
{
name: "防晒霜 · 对比展示",
meta: "透真防晒 / AI 全生 / 6 镜",
prog: ["done", "done", "done", "cur", ""],
pill: { kind: "info", label: "视频生成 4/6" },
action: { label: "继续", href: "/pipeline?stage=4" },
},
{
name: "咖啡冻干粉 · 剧情带货",
meta: "三顿半同款 / 一句话 / 5 镜",
prog: ["done", "done", "fail", "", ""],
pill: { kind: "err", label: "故事板失败" },
action: { label: "查看", href: "/pipeline?stage=3" },
},
];
export default function WorkspacePage() {
return (
<>
<Topbar />
<section className="content">
<div className="welcome page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono-sub">// 05.13 · 周三</span>
<span>·</span>
<b style={{ color: "var(--ink)" }}>3 </b>
</div>
</div>
<div className="actions">
<Link className="btn btn-create" href="/products">
<Icon name="product-plus" size={16} />
</Link>
<Link className="btn btn-primary btn-create" href="/projects/new">
<Icon name="clapperboard" size={16} />
</Link>
</div>
</div>
<div className="stats has-corners" style={{ marginBottom: 36 }}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="stat">
<div className="lbl"> <span className="badge">ALL</span></div>
<div className="v">12</div>
<div className="delta up"> +3</div>
</div>
<div className="stat">
<div className="lbl"> <span className="badge">WIP</span></div>
<div className="v">3</div>
<div className="delta">2 </div>
</div>
<div className="stat">
<div className="lbl"> <span className="badge">DONE</span></div>
<div className="v">8</div>
<div className="delta up"> +33%</div>
</div>
<div className="stat">
<div className="lbl"> <span className="badge">¥</span></div>
<div className="v">¥327<small>.40</small></div>
<div className="usage-bar"><span /></div>
<div className="sub-mono"> ¥162.60 / ¥500</div>
</div>
</div>
<div className="grid2">
<div>
<div className="section-h">
<h2></h2>
<Link className="more" href="/projects">[ ALL · 12 ] </Link>
</div>
<div className="list-card">
{RECENT.map((r) => (
<div className="recent-row" key={r.name}>
<div className="thumb">9:16</div>
<div className="r-meta">
<div className="name">{r.name}</div>
<div className="sub">{r.meta}</div>
</div>
<div className="prog">
{r.prog.map((p, i) => <span key={i} className={p || undefined} />)}
</div>
<span className={`pill pill-${r.pill.kind}`}><span className="dot" />{r.pill.label}</span>
<Link className="btn btn-sm" href={r.action.href}>{r.action.label}</Link>
</div>
))}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<div>
<div className="section-h">
<h2></h2>
<span className="more">[ /shortcuts ]</span>
</div>
<div className="shortcuts">
<Link className="shortcut" href="/products">
<div className="ic"><Icon name="package" /></div>
<div>
<div className="t"></div>
<div className="d">12 SKU</div>
</div>
</Link>
<Link className="shortcut" href="/library">
<div className="ic"><Icon name="images" /></div>
<div>
<div className="t"></div>
<div className="d"> 8 · 14 · 8</div>
</div>
</Link>
<Link className="shortcut" href="/account">
<div className="ic"><Icon name="credit-card" /></div>
<div>
<div className="t"></div>
<div className="d">¥327.40</div>
</div>
</Link>
<Link className="shortcut" href="/projects">
<div className="ic"><Icon name="clapperboard" /></div>
<div>
<div className="t"></div>
<div className="d">12 </div>
</div>
</Link>
</div>
</div>
<div>
<div className="section-h">
<h2></h2>
<span className="more">[ FAQ ]</span>
</div>
<div className="tip">
<strong></strong>
{" "}
<span className="mono-pill">[ ]</span> token
</div>
</div>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,93 @@
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
interface Product {
name: string;
cat: string;
imgs: number;
tags: string[];
thumb: string;
}
const PRODUCTS: Product[] = [
{ name: "透真玻尿酸补水面膜", cat: "美妆个护", imgs: 3, tags: ["熬夜党", "敏感肌", "¥39.9/盒"], thumb: "补水面膜 · 1200×800" },
{ name: "南卡 Lite Pro 蓝牙耳机", cat: "数码 3C", imgs: 5, tags: ["通勤", "运动", "¥199"], thumb: "蓝牙耳机 · 1200×800" },
{ name: "滋啦速食牛肉面 · 6 桶装", cat: "食品饮料", imgs: 4, tags: ["加班", "独居", "¥49.9"], thumb: "速食牛肉面 · 1200×800" },
{ name: "透真清透物理防晒霜", cat: "美妆个护", imgs: 4, tags: ["SPF50", "通勤", "¥69"], thumb: "防晒霜 · 1200×800" },
{ name: "三顿半同款冻干咖啡粉", cat: "食品饮料", imgs: 6, tags: ["提神", "早八", "¥89/24 颗"], thumb: "咖啡冻干粉 · 1200×800" },
{ name: "小熊 4L 可视空气炸锅", cat: "家电", imgs: 5, tags: ["小户型", "健康", "¥159"], thumb: "空气炸锅 · 1200×800" },
{ name: "露露同款裸感瑜伽裤", cat: "服饰", imgs: 8, tags: ["健身房", "通勤", "¥119"], thumb: "瑜伽裤 · 1200×800" },
];
const FILTERS = ["全部", "美妆个护", "数码 3C", "食品饮料", "服饰", "家电"];
export default function ProductsPage() {
return (
<>
<Topbar crumbs={[{ label: "工作台", href: "/" }, { label: "商品库" }]} />
<section className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span>12 </span>
<span className="mono-sub">· SKU</span>
<span>· </span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary btn-create">
<Icon name="product-plus" size={16} />
</button>
</div>
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input className="input" placeholder="搜索商品名称、品牌" />
</div>
{FILTERS.map((f, i) => (
<button key={f} className={`filter-chip${i === 0 ? " active" : ""}`}>
{f}
{i === 0 && <span className="count-mini">12</span>}
</button>
))}
<span className="spacer" />
<button className="filter-chip"></button>
</div>
<div className="product-grid">
{PRODUCTS.map((p) => (
<div className="product-card" key={p.name}>
<div className="placeholder product-thumb">
<span className="ph-frame">{p.thumb}</span>
</div>
<div className="product-body">
<div className="product-name">{p.name}</div>
<div className="product-meta">
<span>{p.cat}</span>
<span className="dot-sep">·</span>
<span>{p.imgs} </span>
</div>
<div className="product-tags">
{p.tags.map((t) => (
<span key={t} className="tag-sm">{t}</span>
))}
</div>
</div>
</div>
))}
<div className="product-card add">
<div className="plus-circle">
<Icon name="plus" size={20} />
</div>
<div></div>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,877 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
/* ============================================================
Data
============================================================ */
interface Product {
id: string;
name: string;
cat: string;
price: number;
imgs: number;
points: string[];
tags: string[];
thumb: string;
}
const PRODUCTS: Product[] = [
{ id: "mask", name: "透真玻尿酸补水面膜", cat: "美妆个护", price: 39.9, imgs: 3, points: ["透明质酸 + B5", "30g 大容量精华", "0 香精 0 酒精"], tags: ["熬夜党", "敏感肌"], thumb: "补水面膜" },
{ id: "earphone",name: "南卡 Lite Pro 蓝牙耳机", cat: "数码 3C", price: 199, imgs: 5, points: ["主动降噪", "32 小时续航", "IP55 防水"], tags: ["通勤", "运动"], thumb: "蓝牙耳机" },
{ id: "noodle", name: "滋啦速食牛肉面 · 6 桶装", cat: "食品饮料", price: 49.9, imgs: 4, points: ["3 分钟出餐", "真材实料牛肉", "0 防腐剂"], tags: ["加班", "独居"], thumb: "速食牛肉面" },
{ id: "sun", name: "透真清透物理防晒霜", cat: "美妆个护", price: 69, imgs: 4, points: ["SPF50 PA+++", "纯物理防晒", "不泛白不假面"], tags: ["SPF50", "通勤"], thumb: "防晒霜" },
{ id: "coffee", name: "三顿半同款冻干咖啡粉", cat: "食品饮料", price: 89, imgs: 6, points: ["冷热水秒溶", "意式深烘", "24 颗轻便装"], tags: ["提神", "早八"], thumb: "咖啡冻干粉" },
{ id: "fryer", name: "小熊 4L 可视空气炸锅", cat: "家电", price: 159, imgs: 5, points: ["可视化窗口", "4L 大容量", "低脂少油"], tags: ["小户型", "健康"], thumb: "空气炸锅" },
{ id: "yoga", name: "露露同款裸感瑜伽裤", cat: "服饰", price: 119, imgs: 8, points: ["裸感面料", "高弹回弹", "随心动随心穿"], tags: ["健身房", "通勤"], thumb: "瑜伽裤" },
];
const RECENT_IDS = ["mask", "sun", "coffee", "earphone"];
const CATS = ["全部", "美妆个护", "数码 3C", "食品饮料", "服饰", "家电"];
type SourceId = "ai" | "theme" | "manual";
const SOURCES: Array<{ id: SourceId; name: string; icon: "sparkles" | "lightbulb" | "doc"; tag: string; desc: string }> = [
{ id: "ai", name: "AI 全生", icon: "sparkles", tag: "最常用", desc: "LLM 全权决定脚本走向,最省事。后续仍可在故事板阶段微调。" },
{ id: "theme", name: "一句话主题", icon: "lightbulb", tag: "轻引导", desc: "你给一句切入主题AI 按此扩写。推荐 530 字。" },
{ id: "manual", name: "自带脚本", icon: "doc", tag: "我已有稿", desc: "粘贴或上传完整脚本,系统按镜头自动切分并适配商品。" },
];
type DurationId = "0-10" | "0-15" | "0-30" | "0-60";
const DURATIONS: Array<{ id: DurationId; label: string; shotsRange: [number, number]; tag: string; completion: number; conversion: number; }> = [
{ id: "0-10", label: "0-10 秒", shotsRange: [3, 4], tag: "黄金完播", completion: 52, conversion: 1.6 },
{ id: "0-15", label: "0-15 秒", shotsRange: [4, 5], tag: "完播率最佳", completion: 42, conversion: 1.8 },
{ id: "0-30", label: "0-30 秒", shotsRange: [6, 8], tag: "卖点详解", completion: 32, conversion: 2.1 },
{ id: "0-60", label: "0-60 秒", shotsRange: [10, 12], tag: "故事化", completion: 26, conversion: 2.4 },
];
type StyleId = "pain" | "review" | "compare";
const STYLES: Array<{ id: StyleId; name: string; note: string; tag?: string; flow: string[] }> = [
{ id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用", flow: ["痛点", "共鸣", "产品", "效果", "引导"] },
{ id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", flow: ["开箱", "首印象", "试用", "对比", "结论"] },
{ id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", flow: ["对照", "差距", "本品", "数据", "购买"] },
];
type PersonaId = "urban" | "bestie" | "ceo" | "reviewer" | "mom" | "genz";
const PERSONAS: Array<{ id: PersonaId; name: string; sub: string; metric: string; defaults: { duration: DurationId; style: StyleId } }> = [
{ id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", defaults: { duration: "0-15", style: "pain" } },
{ id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", defaults: { duration: "0-15", style: "pain" } },
{ id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", defaults: { duration: "0-30", style: "pain" } },
{ id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", defaults: { duration: "0-30", style: "review" } },
{ id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", defaults: { duration: "0-30", style: "pain" } },
{ id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", defaults: { duration: "0-10", style: "compare" } },
];
/* ============================================================
Helpers
============================================================ */
const USER_EMAIL = "airlabsv001@gmail.com";
const ACCOUNT_BALANCE = 327.4;
function avg([a, b]: [number, number]) { return (a + b) / 2; }
/* ============================================================
Component
============================================================ */
type StepNum = 1 | 2 | 3 | 4;
export default function NewProjectPage() {
const router = useRouter();
const [step, setStep] = useState<StepNum>(1);
// Step 1
const [productId, setProductId] = useState<string | null>(null);
const [pickSearch, setPickSearch] = useState("");
const [pickCat, setPickCat] = useState("全部");
// Step 2
const [sourceId, setSourceId] = useState<SourceId | null>(null);
const [themeText, setThemeText] = useState("");
const [manualScript, setManualScript] = useState("");
// Step 3
const [projectName, setProjectName] = useState("");
const [duration, setDuration] = useState<DurationId>("0-15");
const [scriptStyle, setScriptStyle] = useState<StyleId>("pain");
const [persona, setPersona] = useState<PersonaId>("urban");
const [recoDismissed, setRecoDismissed] = useState(false);
const [points, setPoints] = useState<Record<string, boolean>>({});
// Step 4
const [notifyEmail, setNotifyEmail] = useState(true);
const [notifyWeChat, setNotifyWeChat] = useState(false);
const [agreed, setAgreed] = useState(false);
/* ---- derived ---- */
const product = useMemo(() => PRODUCTS.find((p) => p.id === productId) ?? null, [productId]);
const source = useMemo(() => SOURCES.find((s) => s.id === sourceId) ?? null, [sourceId]);
const personaObj = useMemo(() => PERSONAS.find((p) => p.id === persona)!, [persona]);
const durationObj = useMemo(() => DURATIONS.find((d) => d.id === duration)!, [duration]);
const styleObj = useMemo(() => STYLES.find((s) => s.id === scriptStyle)!, [scriptStyle]);
const shots = avg(durationObj.shotsRange);
const completion = durationObj.completion;
const conversion = durationObj.conversion;
// Live cost: roughly 4 line items
const cost = useMemo(() => {
const script = 0.20;
const storyboard = 0.40;
const assets = product ? product.imgs * 0.30 : 0;
const render = shots * 0.30;
const subtotal = script + storyboard + assets + render;
const fee = +(subtotal * 0.05).toFixed(2);
return { script, storyboard, assets, render, subtotal: +subtotal.toFixed(2), fee, total: +(subtotal + fee).toFixed(2) };
}, [product, shots]);
const balanceAfter = +(ACCOUNT_BALANCE - cost.total).toFixed(2);
const lowBalance = balanceAfter < 5;
const etaMinutes = Math.max(3, Math.round(2 + shots * 0.4 + (product?.imgs ?? 0) * 0.2));
// Reco bubble (Step 3)
const recoMismatch =
personaObj.defaults.duration !== duration || personaObj.defaults.style !== scriptStyle;
const showReco = step === 3 && recoMismatch && !recoDismissed;
const recoDuration = DURATIONS.find((d) => d.id === personaObj.defaults.duration)!;
const recoStyle = STYLES.find((s) => s.id === personaObj.defaults.style)!;
/* ---- validation gates ---- */
const canPass1 = !!product;
const canPass2 =
!!source &&
(source.id !== "theme" || themeText.trim().length >= 4) &&
(source.id !== "manual" || manualScript.trim().length >= 20);
const canPass3 = projectName.trim().length >= 2;
const canFinish = agreed && !lowBalance;
/* ---- actions ---- */
function selectProduct(p: Product) {
setProductId(p.id);
// seed defaults derived from product
if (!projectName) setProjectName(`${p.name.split(" ")[0]} · 痛点种草 · v1`);
const seeded: Record<string, boolean> = {};
p.points.forEach((pt, i) => { seeded[pt] = i < 2; });
setPoints(seeded);
}
function applyPreset() {
setDuration(personaObj.defaults.duration);
setScriptStyle(personaObj.defaults.style);
setRecoDismissed(false);
}
function pickPersona(id: PersonaId) {
setPersona(id);
setRecoDismissed(false);
}
function togglePoint(k: string) { setPoints((p) => ({ ...p, [k]: !p[k] })); }
function goPrev() { setStep((s) => (s > 1 ? ((s - 1) as StepNum) : s)); }
function goNext() {
if (step === 1 && !canPass1) return;
if (step === 2 && !canPass2) return;
if (step === 3 && !canPass3) return;
setStep((s) => (s < 4 ? ((s + 1) as StepNum) : s));
}
function startGenerate() {
if (!canFinish) return;
router.push("/pipeline?stage=1");
}
function jumpTo(target: StepNum) {
// only allow going to a completed step or current
if (target < step) setStep(target);
}
/* ---- step rail config ---- */
const stepConfig: Array<{ n: StepNum; label: string; desc: string }> = [
{ n: 1, label: "选择商品", desc: product ? product.name : "未选择" },
{ n: 2, label: "脚本来源", desc: source ? source.name + (source.id === "theme" && themeText ? " · 有主题" : "") : "未选择" },
{ n: 3, label: "项目配置", desc: step >= 3 ? `${durationObj.label} · ${styleObj.name}` : "时长 · 风格 · 人设" },
{ n: 4, label: "确认与计费", desc: `预估 ¥${cost.total.toFixed(2)}` },
];
/* ---- filtered products for Step 1 ---- */
const filteredProducts = useMemo(() => {
return PRODUCTS.filter((p) => {
if (pickCat !== "全部" && p.cat !== pickCat) return false;
if (pickSearch && !p.name.includes(pickSearch)) return false;
return true;
});
}, [pickCat, pickSearch]);
const recentProducts = useMemo(
() => RECENT_IDS.map((id) => PRODUCTS.find((p) => p.id === id)!).filter(Boolean),
[]
);
return (
<>
<Topbar
crumbs={[
{ label: "工作台", href: "/" },
{ label: "视频项目", href: "/projects" },
{ label: "新建项目" },
]}
/>
<section className="content wizard-content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono-sub">// 商品 → 脚本来源 → 配置 → 确认 · 4 步开始生成</span>
</div>
</div>
<div className="actions">
<Link className="btn btn-ghost" href="/projects">退</Link>
</div>
</div>
<div className="wizard-shell">
{/* ── Steps rail ─────────────────────────── */}
<nav className="steps">
{stepConfig.map((s, i) => {
const state: "done" | "active" | "pending" =
s.n < step ? "done" : s.n === step ? "active" : "pending";
const clickable = s.n < step;
return (
<div
key={s.n}
className={`step ${state}${clickable ? " clickable" : ""}${i === stepConfig.length - 1 ? " last" : ""}`}
onClick={() => clickable && jumpTo(s.n)}
>
<div className="num">
{state === "done"
? <Icon name="check" size={12} strokeWidth={1.5} />
: s.n}
</div>
<div>
<div className="step-label">{s.label}</div>
<div className="step-desc">{s.desc}</div>
</div>
</div>
);
})}
</nav>
{/* ── Wiz main ───────────────────────────── */}
<div className="wiz-main">
{/* Step 1 · 选择商品 ───────────────── */}
{step === 1 && (
<section className="card active-step">
<div className="step-h">
<h2> 1 · </h2>
<p> SKU LLM /</p>
</div>
<div className="pick-toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input
className="input"
placeholder="搜索商品名称、品牌"
value={pickSearch}
onChange={(e) => setPickSearch(e.target.value)}
/>
</div>
{CATS.map((c) => (
<button
key={c}
className={`filter-chip${pickCat === c ? " active" : ""}`}
onClick={() => setPickCat(c)}
>
{c}
</button>
))}
</div>
{pickCat === "全部" && !pickSearch && (
<>
<div className="pick-section-h">
<span>使</span>
<span className="count">{recentProducts.length}</span>
</div>
<div className="product-pick-grid">
{recentProducts.map((p) => (
<ProductPickCard key={p.id} p={p} selected={productId === p.id} onSelect={() => selectProduct(p)} />
))}
</div>
</>
)}
<div className="pick-section-h">
<span>{pickCat === "全部" && !pickSearch ? "全部商品" : "搜索结果"}</span>
<span className="count">{filteredProducts.length}</span>
</div>
<div className="product-pick-grid">
{filteredProducts.map((p) => (
<ProductPickCard key={p.id} p={p} selected={productId === p.id} onSelect={() => selectProduct(p)} />
))}
<div className="product-pick add">
<div className="pc"><Icon name="plus" size={16} /></div>
<div></div>
</div>
</div>
</section>
)}
{/* Step 2 · 脚本来源 ───────────────── */}
{step === 2 && (
<>
<CollapsedStep
title="第 1 步 · 选择商品"
onEdit={() => setStep(1)}
body={product && <ProductSummary p={product} />}
/>
<section className="card active-step">
<div className="step-h">
<h2> 2 · </h2>
<p> LLM 稿</p>
</div>
<div className="source-row">
{SOURCES.map((s) => (
<div
key={s.id}
className={`source-card${sourceId === s.id ? " selected" : ""}`}
onClick={() => setSourceId(s.id)}
>
<span className="src-ic"><Icon name={s.icon} size={16} /></span>
<h4>{s.name}</h4>
<span className="src-tag">[ {s.tag} ]</span>
<p className="src-desc">{s.desc}</p>
</div>
))}
</div>
{source && (
<div className="source-detail">
<div className="sd-h">
// 已选 · <b>{source.name}</b>
</div>
{source.id === "ai" && (
<div className="field-hint" style={{ fontSize: 12.5, color: "var(--ink-2)" }}>
AI / / LLM
</div>
)}
{source.id === "theme" && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<input
className="input"
placeholder="例:熬夜党的急救面膜 / 加班吃啥不内疚"
value={themeText}
onChange={(e) => setThemeText(e.target.value)}
/>
<div className="field-hint"> 530 LLM </div>
</div>
)}
{source.id === "manual" && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<textarea
className="input textarea"
style={{ minHeight: 140 }}
placeholder="粘贴你的脚本内容(旁白 / 镜头描述均可,系统会自动切分镜头)"
value={manualScript}
onChange={(e) => setManualScript(e.target.value)}
/>
<div className="field-hint">
20 /
</div>
</div>
)}
</div>
)}
</section>
</>
)}
{/* Step 3 · 项目配置 ───────────────── */}
{step === 3 && (
<>
<CollapsedStep
title="第 1 步 · 选择商品"
onEdit={() => setStep(1)}
body={product && <ProductSummary p={product} />}
/>
<CollapsedStep
title="第 2 步 · 脚本来源"
onEdit={() => setStep(2)}
body={source && <SourceSummary source={source} themeText={themeText} manualScript={manualScript} />}
/>
<section className="card active-step">
<div className="step-h">
<h2> 3 · </h2>
<p> LLM 线 1 </p>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={projectName} onChange={(e) => setProjectName(e.target.value)} />
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<div className="option-row cols-4">
{DURATIONS.map((d) => (
<div
key={d.id}
className={`option-card${duration === d.id ? " selected" : ""}`}
onClick={() => setDuration(d.id)}
>
<h4>{d.label}</h4>
<div className="sub">{d.shotsRange[0]}-{d.shotsRange[1]} </div>
<div className="note">{d.tag}</div>
<div className="metric"> <span className="val">{d.completion}%</span></div>
</div>
))}
</div>
<div className="field-hint"> TOP · LLM </div>
</div>
<div className="field">
<label className="field-label"></label>
<div className="option-row">
{STYLES.map((s) => (
<div
key={s.id}
className={`option-card${scriptStyle === s.id ? " selected" : ""}`}
onClick={() => setScriptStyle(s.id)}
>
<h4>{s.name}</h4>
<div className="note">{s.note}</div>
{s.tag && <span className="tag-mono">[ {s.tag} ]</span>}
</div>
))}
</div>
</div>
<div className="field">
<label className="field-label"></label>
<div className="option-row cols-6">
{PERSONAS.map((p) => (
<div
key={p.id}
className={`option-card${persona === p.id ? " selected" : ""}`}
onClick={() => pickPersona(p.id)}
>
<h4>{p.name}</h4>
<div className="sub">{p.sub}</div>
<div className="metric"><span className="val">{p.metric}</span></div>
</div>
))}
</div>
{showReco && (
<div className="reco-bubble">
<span className="ic"><Icon name="lightbulb" size={14} /></span>
<div className="txt">
<span>
TOP <strong>{recoDuration.label}</strong> + <strong>{recoStyle.name}</strong>
</span>
<span className="meta">
{durationObj.label} · {styleObj.name}
</span>
</div>
<button onClick={applyPreset}></button>
<button className="dismiss" onClick={() => setRecoDismissed(true)} aria-label="忽略">
<Icon name="x" size={14} />
</button>
</div>
)}
</div>
{Object.keys(points).length > 0 && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="hstack" style={{ gap: 6, flexWrap: "wrap" }}>
{Object.entries(points).map(([k, v]) => (
<span key={k} className={`theme-pill${v ? " on" : ""}`} onClick={() => togglePoint(k)}>
{v ? "✓" : "+"} {k}
</span>
))}
</div>
</div>
)}
</section>
</>
)}
{/* Step 4 · 确认与计费 ───────────── */}
{step === 4 && (
<section className="card active-step">
<div className="step-h">
<h2> 4 · </h2>
<p> 3 + 线</p>
</div>
<div className="confirm-grid">
<div className="confirm-card">
<div className="cc-h">
<span>// 商品</span>
<button className="cc-edit" onClick={() => setStep(1)}></button>
</div>
{product && (
<div className="hstack" style={{ gap: 12, alignItems: "flex-start" }}>
<div className="placeholder" style={{ width: 44, height: 56, flexShrink: 0 }}>
<span className="ph-frame">9:16</span>
</div>
<div className="cc-body" style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{product.name}</div>
<div className="ln">
<span>{product.cat}</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<b>¥{product.price}</b>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span>{product.imgs} </span>
</div>
</div>
</div>
)}
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 脚本来源</span>
<button className="cc-edit" onClick={() => setStep(2)}></button>
</div>
{source && (
<div className="cc-body">
<div style={{ fontWeight: 600, fontSize: 13 }}>{source.name}</div>
<div className="ln">
{source.id === "ai" && <span>LLM · Step 3 </span>}
{source.id === "theme" && <span><b>{themeText || "(未填)"}</b></span>}
{source.id === "manual" && <span><b>{manualScript.length}</b> · </span>}
</div>
</div>
)}
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 项目配置</span>
<button className="cc-edit" onClick={() => setStep(3)}></button>
</div>
<div className="cc-body">
<div style={{ fontWeight: 600, fontSize: 13 }}>{projectName}</div>
<div className="ln"><b>{styleObj.name}</b> · {personaObj.name} · {personaObj.sub}</div>
<div className="ln" style={{ fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--ink-3)" }}>
{Object.entries(points).filter(([, v]) => v).map(([k]) => k).join(" / ") || "未选"}
</div>
</div>
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 输出参数</span>
</div>
<div className="cc-body">
<div className="ln"><b>{durationObj.label}</b> · <b>{durationObj.shotsRange[0]}-{durationObj.shotsRange[1]} </b> · 9:16</div>
<div className="ln"> <b>{completion}%</b> · <b>{conversion}%</b></div>
<div className="ln" style={{ fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--ink-3)" }}>
// 数据来源:抖音同品类 TOP 均值
</div>
</div>
</div>
</div>
<div className="section-sub"> · </div>
<div className="bill-list">
<div className="bill-row">
<div className="l"> <span className="l-sub">LLM · 1 稿</span></div>
<div className="qty">× 1</div>
<div className="amt">¥{cost.script.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"></span></div>
<div className="qty">× 1</div>
<div className="amt">¥{cost.storyboard.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"> </span></div>
<div className="qty">× {product?.imgs ?? 0} </div>
<div className="amt">¥{cost.assets.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"> · · </span></div>
<div className="qty">× {shots} </div>
<div className="amt">¥{cost.render.toFixed(2)}</div>
</div>
<div className="bill-row subtotal">
<div className="l"></div>
<div className="qty" />
<div className="amt">¥{cost.subtotal.toFixed(2)}</div>
</div>
<div className="bill-row subtotal">
<div className="l"> <span className="l-sub">5%</span></div>
<div className="qty" />
<div className="amt">¥{cost.fee.toFixed(2)}</div>
</div>
<div className="bill-row total">
<div className="l"></div>
<div className="qty" />
<div className="amt">¥{Math.floor(cost.total)}<small>.{cost.total.toFixed(2).split(".")[1]}</small></div>
</div>
</div>
<div className={`balance-row${lowBalance ? " low" : ""}`}>
<div className="bl">
<Icon name="wallet" size={14} />
<span className="lbl"></span>
<span className="val">¥{ACCOUNT_BALANCE.toFixed(2)}</span>
<span className="arrow"></span>
<span className="lbl"></span>
<span className="val after">¥{balanceAfter.toFixed(2)}</span>
</div>
{lowBalance ? (
<span className="pill pill-err"><span className="dot" /> · <a style={{ marginLeft: 4, textDecoration: "underline" }}></a></span>
) : (
<span className="pill pill-ok"><span className="dot" /></span>
)}
</div>
<div className="section-sub"> · </div>
<div className="eta-block">
<div className="eta-tile">
<div className="lbl"></div>
<div className="v">~ {etaMinutes}<small></small></div>
<div className="desc">// pipeline 5 阶段累计 · 不含人工审核</div>
</div>
<div className="eta-tile">
<div className="lbl"></div>
<div
className={`check-row${notifyEmail ? " on" : ""}`}
style={{ padding: "4px 0" }}
onClick={() => setNotifyEmail((v) => !v)}
>
<span className="check-box" />
<span className="lab"> <span className="mono">{USER_EMAIL}</span></span>
</div>
<div
className={`check-row${notifyWeChat ? " on" : ""}`}
style={{ padding: "4px 0" }}
onClick={() => setNotifyWeChat((v) => !v)}
>
<span className="check-box" />
<span className="lab"> <span className="mono"> · </span></span>
</div>
</div>
</div>
<div className={`tos-row${agreed ? " on" : ""}`} onClick={() => setAgreed((v) => !v)}>
<span className="check-box" />
<span className="lab">
<a></a> <a>使</a>
</span>
</div>
</section>
)}
{/* ── Wiz foot ──────────────────────── */}
<div className="wiz-foot">
<button className="btn btn-ghost" onClick={goPrev} disabled={step === 1}>
</button>
<div className="hstack" style={{ gap: 12 }}>
{step < 4 ? (
<>
<span className="muted-2" style={{ fontSize: 12.5, fontFamily: "var(--mono)", letterSpacing: ".02em" }}>
// 下一步:{stepConfig[step].label}
</span>
<button
className="btn btn-primary btn-lg"
disabled={
(step === 1 && !canPass1) ||
(step === 2 && !canPass2) ||
(step === 3 && !canPass3)
}
onClick={goNext}
>
</button>
</>
) : (
<>
<span className="muted-2" style={{ fontSize: 12.5, fontFamily: "var(--mono)", letterSpacing: ".02em" }}>
// 扣款 ¥{cost.total.toFixed(2)} · 进入 pipeline
</span>
<button className="btn btn-primary btn-lg" disabled={!canFinish} onClick={startGenerate}>
</button>
</>
)}
</div>
</div>
</div>
{/* ── Live preview panel ─────────────────── */}
<aside className="wiz-preview">
<div className="pv-h">
<span></span>
<span className="live-dot">LIVE</span>
</div>
<div className="pv-title">
{projectName || (product ? `${product.name} · 待命名` : "未命名项目")}
</div>
<div className="pv-metrics">
<div className="pv-metric">
<div className="l"></div>
<div className="v">{shots}<small></small></div>
</div>
<div className="pv-metric accent">
<div className="l"></div>
<div className="v">{completion}<small>%</small></div>
</div>
<div className="pv-metric">
<div className="l"></div>
<div className="v">{conversion}<small>%</small></div>
</div>
<div className="pv-metric">
<div className="l"></div>
<div className="v">¥{cost.total.toFixed(2)}</div>
</div>
</div>
{product ? (
<>
<div className="pv-section">
<div className="lbl">// 商品</div>
<ul className="pv-list">
<li>{product.name}</li>
<li>{product.cat} · ¥{product.price}</li>
</ul>
</div>
<div className="pv-section">
<div className="lbl">// 人设 · 风格</div>
<ul className="pv-list">
<li>{personaObj.name} · {personaObj.sub}</li>
<li>{styleObj.name} · {durationObj.tag}</li>
</ul>
</div>
<div className="pv-section">
<div className="lbl">// 脚本走向</div>
<div className="pv-flow">
{styleObj.flow.map((n, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center" }}>
<span className="node">{n}</span>
{i < styleObj.flow.length - 1 && <span className="arrow"></span>}
</span>
))}
</div>
</div>
<div className="pv-section">
<div className="lbl">// 突出卖点</div>
<ul className="pv-list">
{Object.entries(points).filter(([, v]) => v).map(([k]) => <li key={k}>{k}</li>)}
{Object.values(points).every((v) => !v) && (
<li style={{ color: "var(--ink-3)" }}> · LLM </li>
)}
</ul>
</div>
</>
) : (
<div className="pv-section">
<div className="lbl">// 待选择</div>
<ul className="pv-list" style={{ opacity: 0.6 }}>
<li style={{ color: "var(--ink-3)" }}></li>
<li style={{ color: "var(--ink-3)" }}></li>
</ul>
</div>
)}
<div className="pv-foot">
<span>Step {step} / 4 · Restraint</span>
<strong>
{step < 4 ? "进行中" : canFinish ? "就绪" : (lowBalance ? "余额不足" : "待确认")}
</strong>
</div>
</aside>
</div>
</section>
</>
);
}
/* ============================================================
Sub-components
============================================================ */
function ProductPickCard({ p, selected, onSelect }: { p: Product; selected: boolean; onSelect: () => void }) {
return (
<div className={`product-pick${selected ? " selected" : ""}`} onClick={onSelect}>
<div className="placeholder thumb"><span className="ph-frame">9:16</span></div>
<div className="body">
<div className="name">{p.name}</div>
<div className="meta">
{p.cat} · <b>¥{p.price}</b> · {p.imgs}
</div>
<div className="tags">
{p.tags.map((t) => <span key={t} className="tag-s">{t}</span>)}
</div>
</div>
</div>
);
}
function CollapsedStep({ title, onEdit, body }: { title: string; onEdit: () => void; body: React.ReactNode }) {
return (
<section className="card collapsed-step">
<div className="hstack">
<h3>{title}</h3>
<span className="spacer" />
<button className="btn btn-ghost btn-sm" onClick={onEdit}></button>
</div>
<div style={{ marginTop: 10 }}>{body}</div>
</section>
);
}
function ProductSummary({ p }: { p: Product }) {
return (
<div className="hstack" style={{ gap: 12, alignItems: "flex-start" }}>
<div className="placeholder" style={{ width: 44, height: 56, flexShrink: 0 }}>
<span className="ph-frame">9:16</span>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{p.name}</div>
<div className="muted-2 mono" style={{ fontSize: 11.5, marginTop: 3, letterSpacing: ".02em" }}>
{p.cat} · ¥{p.price} · {p.imgs} · {p.points.length}
</div>
</div>
</div>
);
}
function SourceSummary({ source, themeText, manualScript }: { source: { id: SourceId; name: string }; themeText: string; manualScript: string }) {
return (
<div className="hstack" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="pill pill-info"><span className="dot" />{source.name}</span>
{source.id === "theme" && themeText && (
<>
<span className="muted"></span>
<span style={{ fontSize: 13 }}>{themeText}</span>
</>
)}
{source.id === "manual" && (
<>
<span className="muted"></span>
<span style={{ fontSize: 13 }}>{manualScript.length} </span>
</>
)}
{source.id === "ai" && (
<span className="muted-2 mono" style={{ fontSize: 11.5, letterSpacing: ".02em" }}>
// 走向由 Step 3 决定
</span>
)}
</div>
);
}

View File

@ -0,0 +1,224 @@
import Link from "next/link";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
interface Project {
name: string;
sub: string;
product: string;
source: string;
prog: ("done" | "cur" | "fail" | "")[];
step: string;
pill: { kind: "info" | "ok" | "err" | "neutral"; label: string };
updated: string;
}
const PROJECTS: Project[] = [
{
name: "补水面膜 · 痛点种草 · v3",
sub: "6 镜 · 0-15s",
product: "透真补水面膜",
source: "AI 全生",
prog: ["done", "done", "cur", "", ""],
step: "3/5",
pill: { kind: "info", label: "故事板 待确认" },
updated: "12 分钟前",
},
{
name: "速食牛肉面 · 加班治愈",
sub: "4 镜 · 0-12s",
product: "滋啦速食 · 6 桶装",
source: "一句话主题",
prog: ["done", "cur", "", "", ""],
step: "2/5",
pill: { kind: "info", label: "资产生成中" },
updated: "37 分钟前",
},
{
name: "透真防晒 · 通勤对比",
sub: "6 镜 · 0-18s",
product: "透真清透防晒霜",
source: "AI 全生",
prog: ["done", "done", "done", "cur", ""],
step: "4/5",
pill: { kind: "info", label: "视频生成 4/6" },
updated: "2 小时前",
},
{
name: "咖啡冻干 · 早八剧情",
sub: "5 镜 · 0-15s",
product: "三顿半同款冻干",
source: "一句话主题",
prog: ["done", "done", "fail", "", ""],
step: "3/5",
pill: { kind: "err", label: "故事板生成失败" },
updated: "昨天 18:42",
},
{
name: "蓝牙耳机 · 开箱测评",
sub: "5 镜 · 0-15s",
product: "南卡 Lite Pro",
source: "自带脚本",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 7 日",
},
{
name: "瑜伽裤 · 通勤穿搭",
sub: "5 镜 · 0-15s",
product: "露露同款瑜伽裤",
source: "AI 全生",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 6 日",
},
{
name: "空气炸锅 · 小户型",
sub: "4 镜 · 0-12s",
product: "小熊 4L 空气炸锅",
source: "一句话主题",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 4 日",
},
{
name: "补水面膜 · 痛点种草 · v1",
sub: "6 镜 · 0-15s",
product: "透真补水面膜",
source: "AI 全生",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "neutral", label: "已归档" },
updated: "4 月 28 日",
},
];
const TABS = [
{ label: "全部", count: 12, active: true },
{ label: "进行中", count: 3 },
{ label: "待审核", count: 2 },
{ label: "已完成", count: 8 },
{ label: "失败", count: 1 },
];
export default function ProjectsPage() {
return (
<>
<Topbar crumbs={[{ label: "工作台", href: "/" }, { label: "视频项目" }]} />
<section className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span>12 </span>
<span>·</span>
<span>3 </span>
<span>·</span>
<span>8 </span>
</div>
</div>
<div className="actions">
<Link className="btn btn-primary btn-lg btn-create" href="/projects/new">
<Icon name="clapperboard" size={16} />
</Link>
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<div key={t.label} className={`tab${t.active ? " active" : ""}`}>
{t.label}
<span className="count">{t.count}</span>
</div>
))}
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input className="input" placeholder="搜索项目名称、商品" />
</div>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<span className="spacer" />
<div className="view-toggle">
<button></button>
<button className="active"></button>
</div>
</div>
<table className="proj-table">
<thead>
<tr>
<th style={{ width: "32%" }}></th>
<th></th>
<th></th>
<th style={{ width: 200 }}></th>
<th></th>
<th style={{ width: 120 }}></th>
<th style={{ width: 60 }} />
</tr>
</thead>
<tbody>
{PROJECTS.map((p) => (
<tr key={p.name}>
<td>
<div className="proj-row-cell">
<div className="placeholder proj-thumb">
<span className="ph-frame">9:16</span>
</div>
<div>
<div className="proj-name">{p.name}</div>
<div className="proj-sub">{p.sub}</div>
</div>
</div>
</td>
<td>{p.product}</td>
<td><span className="muted">{p.source}</span></td>
<td>
<div className="hstack" style={{ gap: 8 }}>
<div className="prog">
{p.prog.map((s, i) => <span key={i} className={s || undefined} />)}
</div>
<span className="muted-2 mono" style={{ fontSize: 11 }}>{p.step}</span>
</div>
</td>
<td>
<span className={`pill pill-${p.pill.kind}`}>
<span className="dot" />
{p.pill.label}
</span>
</td>
<td className="muted-2">{p.updated}</td>
<td>
<div className="row-actions">
<Link className="icon-btn-sm" href="/pipeline?stage=3" title="继续">
<Icon name="play-tri" size={12} />
</Link>
<button className="icon-btn-sm" title="更多">
<Icon name="more" size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
</>
);
}

View File

@ -0,0 +1,38 @@
export default function GridBg() {
return (
<>
<div className="grid-bg" aria-hidden />
<pre className="scatter" style={{ top: 96, left: 280 }} aria-hidden>
{` · · +
· +XX+
+XXXX·
+X· `}
</pre>
<pre className="scatter" style={{ top: 340, right: 96 }} aria-hidden>
{`+ · ·
XX· ·
·XXXX·+
·++· `}
</pre>
<pre className="scatter" style={{ bottom: 160, left: "42%" }} aria-hidden>
{` · +
+·XX·
·X+ ·
· `}
</pre>
<pre className="scatter" style={{ top: 580, left: 60 }} aria-hidden>
{` +X·
·XX·
+·X·+`}
</pre>
<span className="sq" style={{ top: 238, left: 478 }} aria-hidden />
<span className="sq" style={{ top: 478, left: 1198 }} aria-hidden />
<span className="sq" style={{ bottom: 300, left: 238 }} aria-hidden />
<span className="sq" style={{ top: 718, right: 240 }} aria-hidden />
<span className="corner-tag" style={{ top: 158, left: 34 }} aria-hidden>[ 200 OK ]</span>
<span className="corner-tag" style={{ top: 158, right: 34 }} aria-hidden>[ /v2 ]</span>
<span className="corner-tag" style={{ bottom: 36, left: 34 }} aria-hidden>[ .MP4 · 9:16 ]</span>
<span className="corner-tag" style={{ bottom: 36, right: 34 }} aria-hidden>[ STUDIO ]</span>
</>
);
}

View File

@ -0,0 +1,260 @@
import type { SVGProps } from "react";
type IconName =
| "home"
| "play"
| "folder"
| "package"
| "boxes"
| "images"
| "credit-card"
| "clapperboard"
| "film"
| "video"
| "tile"
| "bars"
| "bars2"
| "key"
| "cog"
| "chev-down"
| "chev-right"
| "search"
| "bell"
| "help"
| "doc"
| "up"
| "plus"
| "product-plus"
| "airshelf"
| "flame"
| "check"
| "x"
| "play-tri"
| "rotate"
| "more"
| "wallet"
| "coin"
| "download"
| "team"
| "lightbulb"
| "sparkles"
| "info"
| "arrow-right";
const PATHS: Record<IconName, React.ReactNode> = {
home: (
<>
<path d="M3 12 12 3l9 9" />
<path d="M5 10v10h14V10" />
</>
),
play: <path d="m6 4 14 8-14 8Z" />,
package: (
<>
<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
<path d="m7.5 4.3 9 5.1" />
</>
),
boxes: (
<>
<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
<path d="m7.5 4.3 9 5.1" />
</>
),
images: (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</>
),
clapperboard: (
<>
<path d="m12.3 3.5 3 4" />
<path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" />
<path d="m6.2 5.3 3.1 3.9" />
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</>
),
film: (
<>
<path d="m12.3 3.5 3 4" />
<path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" />
<path d="m6.2 5.3 3.1 3.9" />
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</>
),
video: (
<>
<path d="m16 13 5.2 3.1a.5.5 0 0 0 .8-.4V8.3a.5.5 0 0 0-.8-.4L16 11" />
<rect x="2" y="6" width="14" height="12" rx="2" />
</>
),
folder: (
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 10h18" />
</>
),
tile: <path d="M3 6h18M3 12h18M3 18h18" />,
bars: (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</>
),
bars2: <path d="M3 21V9M9 21V5M15 21v-8M21 21V11" />,
key: (
<>
<circle cx="9" cy="12" r="6" />
<path d="m15 12 6 0M19 9v6" />
</>
),
cog: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8 2 2 0 0 1-2.8 2.8 1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5 2 2 0 0 1-4 0 1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3 2 2 0 0 1-2.8-2.8 1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8 2 2 0 0 1 2.8-2.8 1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5 2 2 0 0 1 4 0 1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3 2 2 0 0 1 2.8 2.8 1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1Z" />
</>
),
"chev-down": <path d="m6 9 6 6 6-6" />,
"chev-right": <path d="m9 6 6 6-6 6" />,
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</>
),
bell: <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0" />,
help: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 1 1 4 2.2c-.7.5-1.5 1-1.5 2v.3M12 17h.01" />
</>
),
doc: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6M9 13h6M9 17h6" />
</>
),
up: <path d="M12 19V5M5 12l7-7 7 7" />,
plus: <path d="M12 5v14M5 12h14" />,
"product-plus": (
<>
<path d="M12 22V12" />
<path d="M16 17h6" />
<path d="M19 14v6" />
<path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="m7.5 4.3 9 5.1" />
</>
),
airshelf: (
<>
<path d="M4.5 19 12 4.5 19.5 19" />
<path d="M8 14h8" />
<path d="M7 17h10" />
<path d="M9.5 10.3c1.5-1.2 3.5-1.2 5 0" />
</>
),
flame: (
<>
<path d="M8.5 14.5c0-2 1.5-3.4 3.5-5.5.4 2.2 2.8 3.1 2.8 5.6a3.3 3.3 0 0 1-6.3-.1Z" />
<path d="M12 22c4 0 7-2.8 7-7 0-4.7-3.3-7.3-5-12-2 3.3-7 6-7 12a5 5 0 0 0 5 7Z" />
</>
),
check: <path d="M4 12l5 5L20 6" />,
x: <path d="M5 5l14 14M19 5L5 19" />,
"play-tri": <path d="m5 4 14 8-14 8Z" />,
rotate: (
<>
<path d="M4 12a8 8 0 0 1 14-5.5L21 9" />
<path d="M21 4v5h-5" />
<path d="M20 12a8 8 0 0 1-14 5.5L3 15" />
<path d="M3 20v-5h5" />
</>
),
more: (
<>
<circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none" />
</>
),
wallet: (
<>
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="M3 10h18M16 14h2" />
</>
),
"credit-card": (
<>
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M2 10h20" />
</>
),
coin: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" />
</>
),
download: <path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" />,
team: (
<>
<circle cx="9" cy="9" r="3" />
<path d="M3 20c0-3 3-5 6-5s6 2 6 5" />
<circle cx="17" cy="10" r="2.4" />
<path d="M21 19c0-2-1.6-4-4-4-.6 0-1.2.2-1.7.4" />
</>
),
lightbulb: (
<>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
</>
),
sparkles: (
<>
<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" />
<path d="M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4M12 8h.01" />
</>
),
"arrow-right": <path d="M5 12h14M13 6l6 6-6 6" />,
};
interface Props extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
size?: number;
strokeWidth?: number;
}
export default function Icon({ name, size = 16, strokeWidth = 1.5, ...rest }: Props) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
{PATHS[name]}
</svg>
);
}

View File

@ -0,0 +1,79 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Icon from "./Icon";
const NAV = [
{ id: "workspace", label: "工作台", icon: "home" as const, href: "/" },
{ id: "play", label: "试拍台", icon: "play" as const, href: "/play" },
{
id: "projects",
label: "项目",
icon: "clapperboard" as const,
href: "/projects",
chev: true,
},
{ id: "products", label: "商品库", icon: "package" as const, href: "/products" },
{ id: "library", label: "资产库", icon: "images" as const, href: "/library" },
{ id: "usage", label: "用量", icon: "bars2" as const, href: "/usage" },
{ id: "api", label: "API Keys", icon: "key" as const, href: "/api-keys" },
{ id: "settings", label: "设置", icon: "cog" as const, href: "/settings" },
];
function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
export default function Sidebar() {
const pathname = usePathname();
// /account is reached via the user pill in the topbar — not in nav.
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">
<Icon name="airshelf" size={22} />
</div>
<div className="brand-name">Airshelf</div>
<div className="brand-ver">v1</div>
</div>
<div className="search-box">
<Icon name="search" />
<span></span>
<span className="kbd">K</span>
</div>
<nav className="sidebar-nav">
{NAV.map((n) => {
const active = isActive(pathname, n.href);
return (
<Link key={n.id} href={n.href} className={active ? "active" : ""}>
<Icon name={n.icon} />
<span className="label">{n.label}</span>
{n.chev && <Icon name="chev-down" className="chev" />}
</Link>
);
})}
<div className="nav-section"></div>
<div className="nav-item disabled" title="V1.5 上线 · 敬请期待">
<Icon name="team" />
<span className="label"></span>
<span className="badge-mini">V1.5</span>
</div>
</nav>
<div className="aside-foot">
<Link href="/account" className="aside-user">
<div className="av"></div>
<div className="em">li.dao@studio.cn</div>
</Link>
<div className="aside-collapse">
<Icon name="chev-right" size={12} style={{ transform: "rotate(180deg)" }} />
</div>
</div>
</aside>
);
}

View File

@ -0,0 +1,65 @@
import Link from "next/link";
import Icon from "./Icon";
export interface Crumb {
label: string;
href?: string;
}
interface Props {
/** Pass crumbs to show breadcrumbs (e.g. inner pages). Omit to show the team switcher (workspace). */
crumbs?: Crumb[];
balance?: string;
}
export default function Topbar({ crumbs, balance = "¥327.40" }: Props) {
return (
<header className="topbar">
{crumbs && crumbs.length > 0 ? (
<nav className="crumbs">
{crumbs.map((c, i) => {
const last = i === crumbs.length - 1;
const sep = i > 0 ? <span key={`s-${i}`} className="sep">/</span> : null;
return last ? (
<span key={c.label}>{sep}<span className="here">{c.label}</span></span>
) : (
<span key={c.label}>
{sep}
{c.href ? <Link href={c.href}>{c.label}</Link> : <span>{c.label}</span>}
</span>
);
})}
</nav>
) : (
<div className="team-switcher" role="button">
<div className="p"></div>
<Icon name="chev-down" size={12} className="chev" />
</div>
)}
<div className="top-r">
<span className="balance-chip">
<Icon name="credit-card" size={13} />
<strong>{balance}</strong>
</span>
<button className="icon-btn" aria-label="通知">
<Icon name="bell" />
<span className="dot" />
</button>
<button className="pill-btn">
<Icon name="help" />
</button>
<button className="pill-btn">
<Icon name="doc" />
</button>
<button className="pill-btn upgrade">
<Icon name="up" />
</button>
</div>
</header>
);
}

View File

@ -0,0 +1,118 @@
# 部署操作手册
> 本文档说明如何将代码推送到测试环境和生产环境。
> 日常开发在 `dev` 分支,生产发布通过合并到 `master` 分支触发。
---
## 环境说明
| 环境 | 触发分支 | 镜像仓库 | K3s 集群 | 域名 |
|------|---------|---------|---------|------|
| 测试development | `dev` | `cr.volces.com/zyc/...` | `192.168.0.129:6443` | `airflow-studio.test.airlabs.art` |
| 生产production | `master` | `gitea-prod-cn-shanghai.cr.volces.com/prod/...` | `192.168.0.130:6443` | `airflow-studio.airlabs.art` |
---
## 推送到测试环境
只需要把代码推到 `dev` 分支CI/CD 自动触发。
```bash
# 确认当前在 dev 分支
git checkout dev
# 提交代码
git add .
git commit -m "feat: 你的改动描述"
# 推送触发构建
git push origin dev
```
构建完成后在 Gitea Actions 查看进度:
- Build and Push Backend ✅
- Build and Push Web ✅
- Setup Kubectl ✅
- Deploy to K3s ✅
---
## 推送到生产环境
> ⚠️ **注意**:操作完成后必须切回 `dev` 分支,不要在 `master` 上继续开发。
### 完整流程
```bash
# 1. 确保 dev 分支代码是最新的
git checkout dev
git pull origin dev
# 2. 切换到 master 分支
git checkout master
# 3. 合并 dev 的代码
git merge dev
# 4. 推送到远程,触发生产构建
git push origin master
# 5. ⚠️ 立刻切回 dev不要停留在 master
git checkout dev
```
### 如果有合并冲突
```bash
# 解决冲突后
git add .
git commit -m "merge: dev into master"
git push origin master
git checkout dev
```
---
## 构建失败排查
### Build and Push 失败docker pull 超时)
Docker 镜像拉取超时CI 会自动重试 3 次。如仍失败,检查构建机网络。
### Setup Kubectl 失败command not found
kubectl 未安装或下载失败CI 会自动从 daocloud 镜像安装。
### Deploy to K3s 失败i/o timeout
K3s API Server 连接超时CI 会自动重试 3 次(每次间隔 10 秒)。
- 若持续失败,检查 K3s 节点状态:`kubectl get nodes`
- 确认 kubeconfig secret`VOLCANO_TEST_KUBE_CONFIG` / `VOLCANO_PROD_KUBE_CONFIG`)有值
---
## 快速检查部署状态
```bash
# 测试环境
ssh root@14.103.63.199
kubectl get pods -n default
# 生产环境
ssh root@118.196.0.100
kubectl get pods -n default
```
---
## Celery Worker 监控
Celery worker 负责轮询火山 API 的视频生成状态。
```bash
# 查看 worker 日志(测试环境)
kubectl logs -f deployment/celery-worker -n default
# 查看队列积压(测试环境 Redis
redis-cli -h redis-shzlsczo52dft8mia.redis.ivolces.com -p 6379 -a Zyc188208 llen celery
```
`recover_stuck_tasks` 定时任务每 3 分钟自动扫描卡住的任务并重新入队,无需手动干预。

View File

@ -0,0 +1,15 @@
# ClusterIssuer for Let's Encrypt automatic certificate generation & renewal
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: airlabsv001@gmail.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: traefik

View File

@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: airshelf-ingress
annotations:
kubernetes.io/ingress.class: "traefik"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
traefik.ingress.kubernetes.io/router.middlewares: "default-redirect-https@kubernetescrd"
spec:
tls:
- hosts:
- airshelf.airlabs.art
secretName: airshelf-tls
rules:
- host: airshelf.airlabs.art
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: airshelf-web
port:
number: 80

View File

@ -0,0 +1,8 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
spec:
redirectScheme:
scheme: https
permanent: true

View File

@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: airshelf-web
labels:
app: airshelf-web
spec:
replicas: 1
selector:
matchLabels:
app: airshelf-web
template:
metadata:
labels:
app: airshelf-web
spec:
imagePullSecrets:
- name: cr-pull-secret
containers:
- name: airshelf-web
image: ${CI_REGISTRY_IMAGE}/airshelf-web:latest
imagePullPolicy: Always
ports:
- containerPort: 80
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
memory: "32Mi"
cpu: "20m"
limits:
memory: "128Mi"
cpu: "150m"
---
apiVersion: v1
kind: Service
metadata:
name: airshelf-web
spec:
selector:
app: airshelf-web
ports:
- protocol: TCP
port: 80
targetPort: 80

View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"name": "airshelf",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.5.18",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.0.0",
"@types/node": "22.10.5",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"postcss": "8.5.1",
"tailwindcss": "4.0.0",
"typescript": "5.7.3"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "_design_src"]
}

983
_check.html Normal file
View File

@ -0,0 +1,983 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新建项目 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
.wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr) 300px; gap: 36px; align-items: start; max-width: 1400px; }
@media (max-width: 1180px) { .wizard { grid-template-columns: 200px minmax(0, 1fr); } .wiz-preview { display: none; } }
.steps { position: sticky; top: 24px; align-self: start; }
.step { display: flex; gap: 12px; padding: 12px 0; position: relative; }
.step:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 36px; width: 1px; height: calc(100% - 24px); background: var(--border-faint); }
.step .num { width: 24px; height: 24px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--black-alpha-48); flex-shrink: 0; z-index: 1; font-family: var(--font-mono); }
.step.done .num { background: var(--accent-black); border-color: var(--accent-black); color: var(--accent-white); }
.step.active .num { background: var(--heat); border-color: var(--heat); color: var(--accent-white); }
.step .label { font-size: 13.5px; font-weight: 500; color: var(--black-alpha-56); padding-top: 2px; }
.step .desc { font-size: 11.5px; color: var(--black-alpha-48); padding-top: 3px; line-height: 1.4; font-family: var(--font-mono); letter-spacing: .02em; }
.step.active .label { color: var(--accent-black); font-weight: 600; }
.step.done .label { color: var(--black-alpha-56); }
.step.done:not(:last-child)::after { background: var(--accent-black); }
.step.clickable { cursor: pointer; }
.step.clickable:hover .label { color: var(--heat); }
.step.clickable:hover .num { border-color: var(--heat); }
.wiz-pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 22px 24px; margin-bottom: 14px; }
.wiz-pane.active { padding: 26px 28px; position: relative; }
.wiz-pane.active::before, .wiz-pane.active::after { content: ''; position: absolute; width: 14px; height: 14px; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center; background-size: contain; pointer-events: none; }
.wiz-pane.active::before { top: -7px; left: -7px; }
.wiz-pane.active::after { bottom: -7px; right: -7px; }
.wiz-pane.collapsed { padding: 16px 20px; }
.wiz-pane-h { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.wiz-pane-h h3 { font-size: 14px; font-weight: 600; }
.wiz-step-h { margin-bottom: 18px; }
.wiz-step-h h2 { font-size: 20px; font-weight: 600; letter-spacing: -.015em; }
.wiz-step-h p { font-size: 13px; color: var(--black-alpha-56); margin-top: 6px; }
.opt-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.opt-row.cols-4 { grid-template-columns: repeat(4, 1fr); }
.opt-row.cols-6 { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 1280px) { .opt-row.cols-6 { grid-template-columns: repeat(6, 1fr); } }
.opt-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; background: var(--surface); cursor: pointer; position: relative; display: flex; flex-direction: column; min-width: 0; transition: background var(--t-base), border-color var(--t-base); }
.opt-card:hover { background: var(--background-lighter); }
.opt-card.selected { border-color: var(--heat); background: var(--heat-12); }
.opt-card.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
.opt-card h4 { font-size: 13px; font-weight: 600; }
.opt-card .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 3px; letter-spacing: .02em; }
.opt-card .note { font-size: 11.5px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.5; }
.opt-card .metric { margin-top: auto; padding-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.opt-card .metric .val { color: var(--accent-black); font-weight: 500; }
.opt-card.selected .metric .val { color: var(--heat); }
.opt-card .badge { font-family: var(--font-mono); font-size: 9.5px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-48); display: inline-block; margin-top: 8px; letter-spacing: .04em; align-self: flex-start; }
.opt-card.selected .badge { color: var(--heat); border-color: var(--heat-20); }
.theme-pill { display: inline-flex; gap: 4px; align-items: center; height: 28px; padding: 0 12px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 12.5px; cursor: pointer; color: var(--black-alpha-56); transition: background var(--t-base), border-color var(--t-base); }
.theme-pill:hover { background: var(--background-lighter); }
.theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.theme-pill svg { width: 12px; height: 12px; }
.reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--accent-black); }
.reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: var(--heat-12); border-left: 1px solid var(--heat-20); border-top: 1px solid var(--heat-20); transform: rotate(45deg); }
.reco-bubble .ic { color: var(--heat); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
.reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; }
.reco-bubble .txt { flex: 1; line-height: 1.5; }
.reco-bubble .txt strong { color: var(--heat); font-weight: 600; }
.reco-bubble .txt .meta { display: block; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }
.reco-bubble .btn-apply { height: 28px; padding: 0 12px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); font-size: 12px; font-weight: 600; cursor: pointer; flex-shrink: 0; box-shadow: var(--shadow-cta); transition: box-shadow var(--t-base); }
.reco-bubble .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }
.reco-bubble .dismiss { background: transparent; color: var(--black-alpha-48); border: 0; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.reco-bubble .dismiss:hover { color: var(--accent-black); }
.wiz-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border-faint); }
.btn:disabled, .btn.disabled { opacity: .45; cursor: not-allowed; pointer-events: none; }
/* ── pick toolbar (Step 1) ── */
.pick-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.pick-toolbar .search-input { position: relative; flex: 1; max-width: 320px; min-width: 200px; }
.pick-toolbar .search-input svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-48); width: 14px; height: 14px; }
.pick-toolbar .search-input input { width: 100%; height: 32px; padding: 0 12px 0 34px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 12.5px; color: var(--accent-black); font-family: inherit; transition: border-color var(--t-base); }
.pick-toolbar .search-input input:focus { outline: none; border-color: var(--heat); }
.cat-chip { height: 32px; padding: 0 12px; border: 1px solid var(--border-faint); background: var(--surface); border-radius: var(--r-md); font-size: 12.5px; color: var(--black-alpha-56); font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.cat-chip:hover { background: var(--background-lighter); }
.cat-chip.active { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
.pick-section-h { display: flex; align-items: baseline; gap: 8px; margin: 14px 0 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; }
.pick-section-h .count { background: var(--background-lighter); border: 1px solid var(--border-faint); padding: 1px 6px; color: var(--black-alpha-48); font-size: 10px; }
/* ── Step 1 · product picker grid ── */
.product-pick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
@media (max-width: 1100px) { .product-pick-grid { grid-template-columns: repeat(2, 1fr); } }
.product-pick { display: flex; gap: 12px; padding: 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); cursor: pointer; position: relative; transition: background var(--t-base), border-color var(--t-base); min-width: 0; }
.product-pick:hover { background: var(--background-lighter); }
.product-pick.selected { border-color: var(--heat); background: var(--heat-12); }
.product-pick.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
.product-pick .thumb { width: 56px; height: 72px; flex-shrink: 0; }
.product-pick .body { flex: 1; min-width: 0; padding-right: 18px; }
.product-pick .name { font-weight: 600; font-size: 13px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-pick .meta { margin-top: 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.product-pick .meta b { color: var(--accent-black); font-weight: 500; }
.product-pick .tags { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
.product-pick .tag-s { font-size: 10.5px; color: var(--black-alpha-56); background: var(--background-lighter); padding: 1px 6px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); }
.product-pick.selected .tag-s { background: var(--surface); border-color: var(--heat-20); color: var(--heat); }
.product-pick.add { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; border-style: dashed; color: var(--black-alpha-48); min-height: 96px; }
.product-pick.add:hover { color: var(--heat); border-color: var(--heat); background: var(--heat-12); }
.product-pick.add .pc { width: 32px; height: 32px; border: 1px solid currentColor; display: grid; place-items: center; border-radius: var(--r-sm); }
.product-pick.add svg { width: 16px; height: 16px; }
/* ── Step 2 · source-type cards ── */
.source-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.source-card { display: flex; flex-direction: column; gap: 8px; padding: 16px 16px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); cursor: pointer; position: relative; transition: background var(--t-base), border-color var(--t-base); min-height: 132px; }
.source-card:hover { background: var(--background-lighter); }
.source-card.selected { border-color: var(--heat); background: var(--heat-12); }
.source-card.selected::after { content: ''; position: absolute; top: 10px; right: 12px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
.source-card .src-ic { width: 32px; height: 32px; background: var(--background-lighter); color: var(--black-alpha-56); border: 1px solid var(--border-faint); border-radius: var(--r-sm); display: grid; place-items: center; }
.source-card .src-ic svg { width: 16px; height: 16px; }
.source-card.selected .src-ic { background: var(--surface); color: var(--heat); border-color: var(--heat-20); }
.source-card h4 { font-size: 14px; font-weight: 600; }
.source-card .src-tag { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .06em; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 1px 6px; align-self: flex-start; }
.source-card.selected .src-tag { color: var(--heat); border-color: var(--heat-20); }
.source-card .src-desc { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; margin-top: auto; }
.source-detail { margin-top: 16px; padding: 18px 20px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.source-detail .sd-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }
.source-detail .sd-h b { color: var(--accent-black); font-weight: 500; }
/* ── shared field styles ── */
.field { display: block; margin-bottom: 16px; }
.field-label { display: block; font-size: 12.5px; color: var(--black-alpha-56); font-weight: 500; margin-bottom: 6px; }
.field-label .req { color: var(--heat); margin-left: 2px; }
.field-hint { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 4px; }
.input, .textarea { width: 100%; height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-size: 13px; color: var(--accent-black); font-family: inherit; transition: border-color var(--t-base); }
.input:focus, .textarea:focus { outline: none; border-color: var(--heat); }
.textarea { height: auto; padding: 10px 12px; resize: vertical; min-height: 120px; line-height: 1.55; }
/* ── Step 4 · confirm grid / billing / balance / eta / tos ── */
.confirm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }
.confirm-card { position: relative; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); padding: 14px 16px; }
.confirm-card .cc-h { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }
.confirm-card .cc-edit { font-size: 11.5px; color: var(--black-alpha-56); letter-spacing: 0; font-family: var(--font-sans, 'Inter'); text-transform: none; padding: 2px 8px; border: 1px solid var(--border-faint); background: var(--surface); cursor: pointer; border-radius: var(--r-sm); }
.confirm-card .cc-edit:hover { color: var(--heat); border-color: var(--heat-20); }
.confirm-card .cc-body { font-size: 13px; color: var(--accent-black); }
.confirm-card .cc-body .ln { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12.5px; color: var(--black-alpha-56); flex-wrap: wrap; }
.confirm-card .cc-body .ln b { color: var(--accent-black); font-weight: 500; }
.section-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin: 18px 0 10px; }
.bill-list { border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); overflow: hidden; }
.bill-row { display: grid; grid-template-columns: 1fr auto 80px; align-items: baseline; gap: 12px; padding: 11px 16px; border-bottom: 1px solid var(--border-faint); }
.bill-row:last-child { border-bottom: 0; }
.bill-row .l { font-size: 12.5px; color: var(--accent-black); }
.bill-row .l .l-sub { color: var(--black-alpha-48); font-size: 11.5px; margin-left: 6px; }
.bill-row .qty { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; text-align: right; }
.bill-row .amt { font-family: var(--font-mono); font-size: 12.5px; color: var(--accent-black); font-variant-numeric: tabular-nums; text-align: right; }
.bill-row.subtotal { background: var(--background-lighter); }
.bill-row.subtotal .l { color: var(--black-alpha-56); font-size: 12px; }
.bill-row.total { background: var(--background-lighter); border-top: 1px solid var(--black-alpha-12); }
.bill-row.total .l { font-weight: 600; font-size: 13px; }
.bill-row.total .amt { font-size: 16px; font-weight: 600; color: var(--accent-black); }
.bill-row.total .amt small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; margin-left: 2px; }
.balance-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-top: 10px; }
.balance-row .bl { display: flex; align-items: center; gap: 8px; flex: 1; flex-wrap: wrap; }
.balance-row .bl svg { width: 14px; height: 14px; color: var(--black-alpha-56); }
.balance-row .bl .lbl { font-size: 12.5px; color: var(--black-alpha-56); }
.balance-row .bl .val { font-family: var(--font-mono); font-size: 14px; color: var(--accent-black); font-variant-numeric: tabular-nums; font-weight: 500; }
.balance-row .bl .arrow { color: var(--black-alpha-48); font-family: var(--font-mono); }
.balance-row.low .bl .val.after { color: var(--accent-crimson); }
.balance-row .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 500; border: 1px solid; white-space: nowrap; margin-left: auto; }
.balance-row .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.balance-row .pill.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
.balance-row .pill.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
.balance-row .pill.err a { margin-left: 4px; text-decoration: underline; cursor: pointer; }
.eta-block { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
.eta-tile { padding: 14px 16px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); }
.eta-tile .lbl { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 6px; }
.eta-tile .v { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
.eta-tile .v small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; margin-left: 2px; }
.eta-tile .desc { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 6px; }
/* ── SVG checkbox · per design spec (no CSS hack) ── */
.check-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 12.5px; color: var(--black-alpha-56); cursor: pointer; user-select: none; }
.check-row:hover .check-box { border-color: var(--black-alpha-56); }
.check-box { width: 16px; height: 16px; background: var(--surface); border: 1px solid var(--black-alpha-24); flex-shrink: 0; border-radius: var(--r-sm); display: grid; place-items: center; transition: background var(--t-base), border-color var(--t-base); }
.check-row.on .check-box { background: var(--heat); border-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 11px 11px; }
.check-row.on .lab { color: var(--accent-black); }
.check-row .lab b { color: var(--accent-black); font-weight: 500; }
.check-row .lab .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); margin-left: 6px; letter-spacing: .02em; }
.tos-row { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-top: 14px; cursor: pointer; font-size: 12.5px; color: var(--black-alpha-56); user-select: none; }
.tos-row:hover .check-box { border-color: var(--black-alpha-56); }
.tos-row.on { background: var(--heat-12); border-color: var(--heat-20); }
.tos-row.on .check-box { background: var(--heat); border-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 11px 11px; }
.tos-row.on .lab { color: var(--accent-black); }
.tos-row .lab a { color: var(--heat); text-decoration: underline; cursor: pointer; }
/* preview panel */
.wiz-preview { position: sticky; top: 24px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 18px; }
.wiz-preview::before, .wiz-preview::after { content: ''; position: absolute; width: 14px; height: 14px; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center; background-size: contain; pointer-events: none; }
.wiz-preview::before { top: -7px; left: -7px; }
.wiz-preview::after { bottom: -7px; right: -7px; }
.pv-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; margin-bottom: 12px; text-transform: uppercase; display: flex; justify-content: space-between; }
.pv-h .live { display: inline-flex; align-items: center; gap: 5px; color: var(--heat); }
.pv-h .live::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--heat); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .35 } }
.pv-title { font-size: 14px; font-weight: 600; line-height: 1.3; margin-bottom: 14px; word-break: break-all; }
.pv-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border-faint); border: 1px solid var(--border-faint); margin-bottom: 14px; }
.pv-metric { padding: 10px 12px; background: var(--surface); }
.pv-metric .l { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.pv-metric .v { font-size: 18px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; color: var(--accent-black); }
.pv-metric .v small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; }
.pv-metric.accent .v { color: var(--heat); }
.pv-section { margin-top: 14px; }
.pv-section .lbl { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 8px; }
.pv-flow { display: flex; flex-wrap: wrap; gap: 4px 0; font-size: 11.5px; color: var(--black-alpha-56); align-items: center; line-height: 1.7; }
.pv-flow .node { padding: 2px 7px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--accent-black); font-weight: 500; }
.pv-flow .arrow { color: var(--heat); margin: 0 5px; display: inline-flex; align-items: center; }
.pv-flow .arrow svg { display: block; }
.pv-list { list-style: none; padding: 0; margin: 0; }
.pv-list li { font-size: 11.5px; color: var(--black-alpha-56); padding: 4px 0; display: flex; align-items: center; gap: 6px; }
.pv-list li::before { content: ''; width: 11px; height: 11px; flex-shrink: 0; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23FA5D19' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12l5 5L20 6'/%3E%3C/svg%3E") no-repeat center; background-size: contain; }
.pv-foot { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--border-faint); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); display: flex; justify-content: space-between; }
.pv-foot strong { color: var(--accent-black); font-weight: 500; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>新建项目</h1>
<div class="sub"><span class="mono">// 商品 → 脚本来源 → 配置 → 确认 · 4 步开始生成</span></div>
</div>
<div class="actions">
<a class="btn btn-ghost" href="projects.html">退出</a>
</div>
</div>
<div class="wizard">
<nav class="steps" id="rail"></nav>
<div id="wiz-body"></div>
<aside class="wiz-preview" id="preview"></aside>
</div>
</div>
<script src="assets/shell.js"></script>
<script src="assets/new-product-drawer.js"></script>
<script>Shell.render({ active: 'projects', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: '新建项目' }] });</script>
<script>
/* ============================================================
新建项目 · 4 步动态向导 (vanilla JS state machine)
============================================================ */
(function () {
'use strict';
/* ---------- data ---------- */
const PRODUCTS = [
{ id: 'mask', name: '透真玻尿酸补水面膜', cat: '美妆个护', price: 39.9, imgs: 3, points: ['透明质酸 + B5', '30g 大容量精华', '0 香精 0 酒精'], tags: ['熬夜党', '敏感肌'] },
{ id: 'earphone', name: '南卡 Lite Pro 蓝牙耳机', cat: '数码 3C', price: 199, imgs: 5, points: ['主动降噪', '32 小时续航', 'IP55 防水'], tags: ['通勤', '运动'] },
{ id: 'noodle', name: '滋啦速食牛肉面 · 6 桶装', cat: '食品饮料', price: 49.9, imgs: 4, points: ['3 分钟出餐', '真材实料牛肉', '0 防腐剂'], tags: ['加班', '独居'] },
{ id: 'sun', name: '透真清透物理防晒霜', cat: '美妆个护', price: 69, imgs: 4, points: ['SPF50 PA+++', '纯物理防晒', '不泛白不假面'], tags: ['SPF50', '通勤'] },
{ id: 'coffee', name: '三顿半同款冻干咖啡粉', cat: '食品饮料', price: 89, imgs: 6, points: ['冷热水秒溶', '意式深烘', '24 颗轻便装'], tags: ['提神', '早八'] },
{ id: 'fryer', name: '小熊 4L 可视空气炸锅', cat: '家居家电', price: 159, imgs: 5, points: ['可视化窗口', '4L 大容量', '低脂少油'], tags: ['小户型', '健康'] },
{ id: 'yoga', name: '露露同款裸感瑜伽裤', cat: '运动户外', price: 119, imgs: 8, points: ['裸感面料', '高弹回弹', '随心动随心穿'], tags: ['健身房', '通勤'] },
];
const RECENT_IDS = ['mask', 'sun', 'coffee', 'earphone'];
const CATS = ['全部', '美妆个护', '数码 3C', '食品饮料', '家居家电', '运动户外'];
const SOURCES = [
{ id: 'ai', name: 'AI 全生', tag: '最常用', desc: 'LLM 全权决定脚本走向,最省事。后续仍可在故事板阶段微调。',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/><path d="M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z"/></svg>' },
{ id: 'theme', name: '一句话主题', tag: '轻引导', desc: '你给一句切入主题,AI 按此扩写。推荐 530 字。',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>' },
{ id: 'manual', name: '自带脚本', tag: '我已有稿', desc: '粘贴或上传完整脚本,系统按镜头自动切分并适配商品。',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h6"/></svg>' },
];
const DURATIONS = [
{ id: '0-10', label: '0-10 秒', shots: [3, 4], tag: '黄金完播', completion: 52, conversion: 1.6 },
{ id: '0-15', label: '0-15 秒', shots: [4, 5], tag: '完播率最佳', completion: 42, conversion: 1.8 },
{ id: '0-30', label: '0-30 秒', shots: [6, 8], tag: '卖点详解', completion: 32, conversion: 2.1 },
{ id: '0-60', label: '0-60 秒', shots: [10, 12], tag: '故事化', completion: 26, conversion: 2.4 },
];
const STYLES = [
{ id: 'pain', name: '痛点种草', note: '用户痛点切入,以「我懂你」的口吻引出产品。', tag: '最常用', flow: ['痛点', '共鸣', '产品', '效果', '引导'] },
{ id: 'review', name: '开箱测评', note: '朋友式分享,从开箱到使用感受娓娓道来。', flow: ['开箱', '首印象', '试用', '对比', '结论'] },
{ id: 'compare', name: '对比展示', note: '「用前 vs 用后 / 同类 vs 本品」直观呈现。', flow: ['对照', '差距', '本品', '数据', '购买'] },
];
const PERSONAS = [
{ id: 'urban', name: '都市白领女性', sub: '25-30 岁', metric: '大盘消费力', defaults: { duration: '0-15', style: 'pain' } },
{ id: 'bestie', name: '闺蜜种草', sub: '邻家女孩', metric: '复购最高', defaults: { duration: '0-15', style: 'pain' } },
{ id: 'ceo', name: '总裁亲选', sub: '创始人 IP', metric: '30 万销额案例', defaults: { duration: '0-30', style: 'pain' } },
{ id: 'reviewer', name: '专业测评师', sub: '垂类达人', metric: '互动 +30%', defaults: { duration: '0-30', style: 'review' } },
{ id: 'mom', name: '实用宝妈', sub: '家庭决策者', metric: '母婴/家清稳', defaults: { duration: '0-30', style: 'pain' } },
{ id: 'genz', name: '学生党', sub: 'Z 世代 18-24', metric: '平价快消', defaults: { duration: '0-10', style: 'compare' } },
];
const USER_EMAIL = 'li@shop.com';
const ACCOUNT_BALANCE = 327.40;
/* ---------- state ---------- */
const state = {
currentStep: 1,
productId: null,
pickSearch: '',
pickCat: '全部',
sourceId: null,
themeText: '',
manualScript: '',
projectName: '',
duration: '0-15',
scriptStyle: 'pain',
persona: 'urban',
points: {},
recoDismissed: false,
notifyEmail: true,
notifyWeChat: false,
agreed: false,
};
/* ---------- helpers ---------- */
function $(sel) { return document.querySelector(sel); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function getProduct() { return PRODUCTS.find(p => p.id === state.productId) || null; }
function getSource() { return SOURCES.find(s => s.id === state.sourceId) || null; }
function getPersona() { return PERSONAS.find(p => p.id === state.persona); }
function getDuration() { return DURATIONS.find(d => d.id === state.duration); }
function getStyle() { return STYLES.find(s => s.id === state.scriptStyle); }
function getShots() { const d = getDuration(); return (d.shots[0] + d.shots[1]) / 2; }
function getCost() {
const p = getProduct();
const script = 0.20;
const sb = 0.40;
const assets = p ? p.imgs * 0.30 : 0;
const render = getShots() * 0.30;
const subtotal = script + sb + assets + render;
const fee = +(subtotal * 0.05).toFixed(2);
return {
script: script.toFixed(2),
sb: sb.toFixed(2),
assets: assets.toFixed(2),
render: render.toFixed(2),
subtotal: subtotal.toFixed(2),
fee: fee.toFixed(2),
total: +(subtotal + fee).toFixed(2),
};
}
function balanceAfter() { return +(ACCOUNT_BALANCE - getCost().total).toFixed(2); }
function etaMinutes() {
const p = getProduct();
return Math.max(3, Math.round(2 + getShots() * 0.4 + (p ? p.imgs * 0.2 : 0)));
}
function canPass1() { return !!state.productId; }
function canPass2() {
const s = getSource(); if (!s) return false;
if (s.id === 'theme') return state.themeText.trim().length >= 4;
if (s.id === 'manual') return state.manualScript.trim().length >= 20;
return true;
}
function canPass3() { return state.projectName.trim().length >= 2; }
function canFinish() { return state.agreed && balanceAfter() >= 5; }
/* ---------- icons ---------- */
const ICONS = {
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>',
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>',
x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l14 14M19 5L5 19"/></svg>',
bulb: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>',
arrow: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>',
wallet: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 14h2"/></svg>',
};
/* ---------- actions ---------- */
function selectProduct(id) {
state.productId = id;
const p = getProduct();
if (!state.projectName) {
state.projectName = p.name.split(' ')[0] + ' · 痛点种草 · v1';
}
state.points = {};
p.points.forEach((pt, i) => { state.points[pt] = i < 2; });
render();
}
function selectSource(id) {
state.sourceId = id;
render();
}
function goNext() {
if (state.currentStep === 1 && !canPass1()) return;
if (state.currentStep === 2 && !canPass2()) return;
if (state.currentStep === 3 && !canPass3()) return;
if (state.currentStep < 4) state.currentStep++;
render();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function goPrev() {
if (state.currentStep > 1) state.currentStep--;
render();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function jumpTo(n) {
if (n < state.currentStep) {
state.currentStep = n;
render();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function applyPreset() {
const p = getPersona();
state.duration = p.defaults.duration;
state.scriptStyle = p.defaults.style;
state.recoDismissed = false;
render();
}
function startGenerate() {
if (!canFinish()) return;
if (typeof Shell !== 'undefined' && Shell.toast) {
Shell.toast('开始生成项目', '扣款 ¥' + getCost().total.toFixed(2) + ' · pipeline#stage-1');
}
setTimeout(() => { location.href = 'pipeline.html#stage-1'; }, 600);
}
function openNewProduct() {
if (!window.NewProductDrawer) {
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('Drawer 未加载', '检查 new-product-drawer.js');
return;
}
window.NewProductDrawer.open({
onSave: function (p) {
// p = { id, name, cat, target, points: string[], images: [...], imgs: N }
// 适配 wizard 数据结构
const product = {
id: p.id,
name: p.name,
cat: p.cat,
price: null, // 表单暂未收集价格,显示时跳过
imgs: p.imgs,
points: p.points,
tags: p.target ? p.target.split(/[ ,、、]+/).filter(Boolean).slice(0, 3) : [],
};
// 置顶插入,让用户立刻看到
PRODUCTS.unshift(product);
// 自动选中(同时种子项目名 / 卖点)
selectProduct(product.id);
}
});
}
// expose for inline onclick
window._wiz = {
selectProduct, selectSource, goNext, goPrev, jumpTo, applyPreset, startGenerate, openNewProduct,
setSearch: v => { state.pickSearch = v; renderStep1Only(); },
setCat: v => { state.pickCat = v; renderStep1Only(); },
setTheme: v => { state.themeText = v; updateFootOnly(); updatePreviewLive(); },
setScript: v => { state.manualScript = v; updateFootOnly(); },
setName: v => { state.projectName = v; updatePreviewLive(); updateFootOnly(); updateRailOnly(); },
setDur: v => { state.duration = v; render(); },
setStyle: v => { state.scriptStyle = v; render(); },
setPersona:v => { state.persona = v; state.recoDismissed = false; render(); },
togglePt: k => { state.points[k] = !state.points[k]; render(); },
dismissReco: () => { state.recoDismissed = true; render(); },
toggleEmail: () => { state.notifyEmail = !state.notifyEmail; render(); },
toggleWeChat: () => { state.notifyWeChat = !state.notifyWeChat; render(); },
toggleTos: () => { state.agreed = !state.agreed; render(); },
};
/* ============================================================
RENDER
============================================================ */
function railConfig() {
const p = getProduct(), s = getSource(), pe = getPersona(), du = getDuration(), st = getStyle();
return [
{ n: 1, label: '选择商品', desc: p ? p.name : '未选择' },
{ n: 2, label: '脚本来源', desc: s ? s.name : '未选择' },
{ n: 3, label: '项目配置', desc: state.currentStep >= 3 ? (du.label + ' · ' + st.name) : '时长 · 风格 · 人设' },
{ n: 4, label: '确认与计费', desc: '预估 ¥' + getCost().total.toFixed(2) },
];
}
function renderRail() {
const cfg = railConfig();
const html = cfg.map(s => {
const stt = s.n < state.currentStep ? 'done'
: s.n === state.currentStep ? 'active' : '';
const clickable = s.n < state.currentStep;
const numContent = stt === 'done' ? ICONS.check : s.n;
return `<div class="step ${stt}${clickable ? ' clickable' : ''}" ${clickable ? 'onclick="_wiz.jumpTo(' + s.n + ')"' : ''}>
<div class="num">${numContent}</div>
<div>
<div class="label">${esc(s.label)}</div>
<div class="desc">${esc(s.desc)}</div>
</div>
</div>`;
}).join('');
$('#rail').innerHTML = html;
}
function productPickHTML(p) {
const selected = state.productId === p.id;
return `<div class="product-pick${selected ? ' selected' : ''}" onclick="_wiz.selectProduct('${p.id}')">
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="body">
<div class="name">${esc(p.name)}</div>
<div class="meta">${esc(p.cat)}${p.price != null ? ' · <b>¥' + p.price + '</b>' : ''} · ${p.imgs} 张图</div>
<div class="tags">${p.tags.map(t => `<span class="tag-s">${esc(t)}</span>`).join('')}</div>
</div>
</div>`;
}
function renderStep1() {
const q = state.pickSearch.trim();
const filtered = PRODUCTS.filter(p => {
if (state.pickCat !== '全部' && p.cat !== state.pickCat) return false;
if (q && !p.name.includes(q)) return false;
return true;
});
const recent = RECENT_IDS.map(id => PRODUCTS.find(p => p.id === id)).filter(Boolean);
const showRecent = state.pickCat === '全部' && !q;
return `<div class="wiz-pane active" data-step="1">
<div class="wiz-step-h">
<h2>第 1 步 · 选择商品</h2>
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
</div>
<div class="pick-toolbar">
<div class="search-input">
${ICONS.search}
<input type="text" placeholder="搜索商品名称、品牌" value="${esc(state.pickSearch)}" oninput="_wiz.setSearch(this.value)">
</div>
${CATS.map(c => `<button class="cat-chip${state.pickCat === c ? ' active' : ''}" onclick="_wiz.setCat('${esc(c)}')">${esc(c)}</button>`).join('')}
</div>
${showRecent ? `
<div class="pick-section-h"><span>最近使用</span><span class="count">${recent.length}</span></div>
<div class="product-pick-grid">
${recent.map(productPickHTML).join('')}
</div>
` : ''}
<div class="pick-section-h">
<span>${showRecent ? '全部商品' : '搜索结果'}</span>
<span class="count">${filtered.length}</span>
</div>
<div class="product-pick-grid">
${filtered.map(productPickHTML).join('')}
<div class="product-pick add" onclick="_wiz.openNewProduct()">
<div class="pc">${ICONS.plus}</div>
<div>新建商品</div>
</div>
</div>
</div>`;
}
function renderStep2() {
const s = getSource();
let detail = '';
if (s) {
if (s.id === 'ai') {
detail = `<div class="source-detail">
<div class="sd-h">// 已选 · <b>${esc(s.name)}</b></div>
<div class="field-hint" style="font-size: 12.5px; color: var(--black-alpha-72);">AI 全生模式无需额外输入。下一步选定时长 / 风格 / 人设后,LLM 会自动决定切入点和卖点权重。</div>
</div>`;
} else if (s.id === 'theme') {
detail = `<div class="source-detail">
<div class="sd-h">// 已选 · <b>${esc(s.name)}</b></div>
<div class="field" style="margin-bottom: 0;">
<label class="field-label">一句话主题<span class="req">*</span></label>
<input class="input" placeholder="例:熬夜党的急救面膜 / 加班吃啥不内疚" value="${esc(state.themeText)}" oninput="_wiz.setTheme(this.value)">
<div class="field-hint">推荐 530 字。这句话会作为 LLM 扩写的锚点,越具体越聚焦。</div>
</div>
</div>`;
} else if (s.id === 'manual') {
detail = `<div class="source-detail">
<div class="sd-h">// 已选 · <b>${esc(s.name)}</b></div>
<div class="field" style="margin-bottom: 0;">
<label class="field-label">粘贴脚本内容<span class="req">*</span></label>
<textarea class="textarea" placeholder="粘贴你的脚本内容(旁白 / 镜头描述均可,系统会自动切分镜头)" oninput="_wiz.setScript(this.value)">${esc(state.manualScript)}</textarea>
<div class="field-hint">最少 20 字。镜头数由你的脚本自然段落决定,时长 / 风格仍会影响后期渲染节奏。</div>
</div>
</div>`;
}
}
return `<div class="wiz-pane active" data-step="2">
<div class="wiz-step-h">
<h2>第 2 步 · 脚本来源</h2>
<p>决定 LLM 如何获得初稿脚本。三种方式由「最省事」到「最保真原意」。</p>
</div>
<div class="source-row">
${SOURCES.map(s => `<div class="source-card${state.sourceId === s.id ? ' selected' : ''}" onclick="_wiz.selectSource('${s.id}')">
<span class="src-ic">${s.icon}</span>
<h4>${esc(s.name)}</h4>
<span class="src-tag">[ ${esc(s.tag)} ]</span>
<p class="src-desc">${esc(s.desc)}</p>
</div>`).join('')}
</div>
${detail}
</div>`;
}
function renderStep3() {
const personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();
const recoMismatch = personaObj.defaults.duration !== state.duration || personaObj.defaults.style !== state.scriptStyle;
const showReco = recoMismatch && !state.recoDismissed;
const recoDur = DURATIONS.find(d => d.id === personaObj.defaults.duration);
const recoStyle = STYLES.find(s => s.id === personaObj.defaults.style);
return `<div class="wiz-pane active" data-step="3">
<div class="wiz-step-h">
<h2>第 3 步 · 项目配置</h2>
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
</div>
<div class="field">
<label class="field-label">项目名称<span class="req">*</span></label>
<input class="input" value="${esc(state.projectName)}" oninput="_wiz.setName(this.value)">
</div>
<div class="field">
<label class="field-label">视频时长<span class="req">*</span></label>
<div class="opt-row cols-4">
${DURATIONS.map(d => `<div class="opt-card${state.duration === d.id ? ' selected' : ''}" onclick="_wiz.setDur('${d.id}')">
<h4>${esc(d.label)}</h4>
<div class="sub">${d.shots[0]}-${d.shots[1]} 镜</div>
<div class="note">${esc(d.tag)}</div>
<div class="metric">完播 <span class="val">${d.completion}%</span></div>
</div>`).join('')}
</div>
<div class="field-hint">数据来源:抖音同品类 TOP 视频均值 · 实际镜头数由 LLM 决定</div>
</div>
<div class="field">
<label class="field-label">脚本风格</label>
<div class="opt-row">
${STYLES.map(s => `<div class="opt-card${state.scriptStyle === s.id ? ' selected' : ''}" onclick="_wiz.setStyle('${s.id}')">
<h4>${esc(s.name)}</h4>
<div class="note">${esc(s.note)}</div>
${s.tag ? `<span class="badge">[ ${esc(s.tag)} ]</span>` : ''}
</div>`).join('')}
</div>
</div>
<div class="field">
<label class="field-label">人物设定</label>
<div class="opt-row cols-6">
${PERSONAS.map(p => `<div class="opt-card${state.persona === p.id ? ' selected' : ''}" onclick="_wiz.setPersona('${p.id}')">
<h4>${esc(p.name)}</h4>
<div class="sub">${esc(p.sub)}</div>
<div class="metric"><span class="val">${esc(p.metric)}</span></div>
</div>`).join('')}
</div>
${showReco ? `<div class="reco-bubble">
<span class="ic">${ICONS.bulb}</span>
<div class="txt">
<span>抖音同人设 TOP 视频更常用 <strong>${esc(recoDur.label)}</strong> + <strong>${esc(recoStyle.name)}</strong></span>
<span class="meta">当前 ${esc(durObj.label)} · ${esc(styleObj.name)} → 推荐换为同人设最优组合</span>
</div>
<button class="btn-apply" onclick="_wiz.applyPreset()">一键套用</button>
<button class="dismiss" onclick="_wiz.dismissReco()" aria-label="忽略">${ICONS.x}</button>
</div>` : ''}
</div>
${Object.keys(state.points).length > 0 ? `<div class="field" style="margin-bottom: 0;">
<label class="field-label">关键卖点(可勾选要重点突出的)</label>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
${Object.entries(state.points).map(([k, v]) => `<span class="theme-pill${v ? ' active' : ''}" onclick="_wiz.togglePt('${esc(k).replace(/'/g, "\\'")}')">${v ? ICONS.check : ICONS.plus}<span>${esc(k)}</span></span>`).join('')}
</div>
</div>` : ''}
</div>`;
}
function renderStep4() {
const p = getProduct(), s = getSource(), personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();
const c = getCost();
const ba = balanceAfter();
const low = ba < 5;
const eta = etaMinutes();
const pointsList = Object.entries(state.points).filter(([, v]) => v).map(([k]) => k).join(' / ') || '未选';
return `<div class="wiz-pane active" data-step="4">
<div class="wiz-step-h">
<h2>第 4 步 · 确认与计费</h2>
<p>核对前 3 步的选择 + 计费明细。点击「开始生成」会立刻扣款并进入流水线。</p>
</div>
<div class="confirm-grid">
<div class="confirm-card">
<div class="cc-h"><span>// 商品</span><button class="cc-edit" onclick="_wiz.jumpTo(1)">修改</button></div>
${p ? `<div style="display:flex; gap:12px; align-items:flex-start;">
<div class="placeholder" style="width:44px; height:56px; flex-shrink:0;"><span class="ph-frame">9:16</span></div>
<div class="cc-body" style="min-width:0;">
<div style="font-weight:600; font-size:13px;">${esc(p.name)}</div>
<div class="ln">${esc(p.cat)}${p.price != null ? ' <span style="color: var(--black-alpha-32);">·</span> <b>¥' + p.price + '</b>' : ''} <span style="color: var(--black-alpha-32);">·</span> ${p.imgs} 张图</div>
</div>
</div>` : '<div class="cc-body">未选择</div>'}
</div>
<div class="confirm-card">
<div class="cc-h"><span>// 脚本来源</span><button class="cc-edit" onclick="_wiz.jumpTo(2)">修改</button></div>
${s ? `<div class="cc-body">
<div style="font-weight:600; font-size:13px;">${esc(s.name)}</div>
<div class="ln">${s.id === 'ai' ? 'LLM 全权 · 走向由 Step 3 决定'
: s.id === 'theme' ? '主题:<b style="margin-left:4px;">' + esc(state.themeText || '(未填)') + '</b>'
: '<b>' + state.manualScript.length + '</b> 字 · 自动切镜'}</div>
</div>` : '<div class="cc-body">未选择</div>'}
</div>
<div class="confirm-card">
<div class="cc-h"><span>// 项目配置</span><button class="cc-edit" onclick="_wiz.jumpTo(3)">修改</button></div>
<div class="cc-body">
<div style="font-weight:600; font-size:13px;">${esc(state.projectName)}</div>
<div class="ln"><b>${esc(styleObj.name)}</b> · ${esc(personaObj.name)} · ${esc(personaObj.sub)}</div>
<div class="ln" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);">卖点:${esc(pointsList)}</div>
</div>
</div>
<div class="confirm-card">
<div class="cc-h"><span>// 输出参数</span></div>
<div class="cc-body">
<div class="ln"><b>${esc(durObj.label)}</b> · <b>${durObj.shots[0]}-${durObj.shots[1]} 镜</b> · 9:16</div>
<div class="ln">预估完播 <b>${durObj.completion}%</b> · 预估转化 <b>${durObj.conversion}%</b></div>
<div class="ln" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);">// 数据来源:抖音同品类 TOP 均值</div>
</div>
</div>
</div>
<div class="section-sub">计费明细 · 按量计费</div>
<div class="bill-list">
<div class="bill-row"><div class="l">脚本生成 <span class="l-sub">LLM · 1 稿</span></div><div class="qty">× 1</div><div class="amt">¥${c.script}</div></div>
<div class="bill-row"><div class="l">故事板生成 <span class="l-sub">含分镜画面描述</span></div><div class="qty">× 1</div><div class="amt">¥${c.sb}</div></div>
<div class="bill-row"><div class="l">资产生成 <span class="l-sub">主图 → 镜头素材</span></div><div class="qty">× ${p ? p.imgs : 0} 张</div><div class="amt">¥${c.assets}</div></div>
<div class="bill-row"><div class="l">视频渲染 <span class="l-sub">合成 · 配乐 · 字幕</span></div><div class="qty">× ${getShots()} 镜</div><div class="amt">¥${c.render}</div></div>
<div class="bill-row subtotal"><div class="l">小计</div><div class="qty"></div><div class="amt">¥${c.subtotal}</div></div>
<div class="bill-row subtotal"><div class="l">平台服务费 <span class="l-sub">5%</span></div><div class="qty"></div><div class="amt">¥${c.fee}</div></div>
<div class="bill-row total"><div class="l">合计</div><div class="qty"></div><div class="amt">¥${Math.floor(c.total)}<small>.${c.total.toFixed(2).split('.')[1]}</small></div></div>
</div>
<div class="balance-row${low ? ' low' : ''}">
<div class="bl">
${ICONS.wallet}
<span class="lbl">账户余额</span>
<span class="val">¥${ACCOUNT_BALANCE.toFixed(2)}</span>
<span class="arrow"></span>
<span class="lbl">扣款后</span>
<span class="val after">¥${ba.toFixed(2)}</span>
</div>
${low
? `<span class="pill err"><span class="dot"></span>余额不足 · <a>去充值</a></span>`
: `<span class="pill ok"><span class="dot"></span>余额充足</span>`}
</div>
<div class="section-sub">预估耗时 · 通知</div>
<div class="eta-block">
<div class="eta-tile">
<div class="lbl">预估出片</div>
<div class="v">~ ${eta}<small>分钟</small></div>
<div class="desc">// pipeline 5 阶段累计 · 不含人工审核</div>
</div>
<div class="eta-tile">
<div class="lbl">完成后通知</div>
<div class="check-row${state.notifyEmail ? ' on' : ''}" onclick="_wiz.toggleEmail()" style="padding:4px 0;">
<span class="check-box"></span>
<span class="lab">邮件 <span class="mono">${esc(USER_EMAIL)}</span></span>
</div>
<div class="check-row${state.notifyWeChat ? ' on' : ''}" onclick="_wiz.toggleWeChat()" style="padding:4px 0;">
<span class="check-box"></span>
<span class="lab">微信 <span class="mono">未绑定 · 去绑定</span></span>
</div>
</div>
</div>
<div class="tos-row${state.agreed ? ' on' : ''}" onclick="_wiz.toggleTos()">
<span class="check-box"></span>
<span class="lab">我已阅读并同意 <a>《按量计费协议》</a><a>《商品素材使用授权》</a></span>
</div>
</div>`;
}
function renderCollapsedStep(n) {
const p = getProduct(), s = getSource();
let title = '', body = '';
if (n === 1) {
title = '第 1 步 · 选择商品';
body = p
? `<div style="display:flex; gap:12px; align-items:flex-start;">
<div class="placeholder" style="width:44px; height:56px; flex-shrink:0;"><span class="ph-frame">9:16</span></div>
<div style="min-width:0;">
<div style="font-weight:600; font-size:13.5px;">${esc(p.name)}</div>
<div class="mono" style="font-size:11.5px; color: var(--black-alpha-48); margin-top:3px; letter-spacing:.02em;">${esc(p.cat)}${p.price != null ? ' · ¥' + p.price : ''} · ${p.imgs} 张图 · ${p.points.length} 个卖点</div>
</div>
</div>`
: '<span class="mono" style="color: var(--black-alpha-48); font-size: 11.5px;">未选择</span>';
} else if (n === 2) {
title = '第 2 步 · 脚本来源';
if (s) {
let extra = '';
if (s.id === 'theme' && state.themeText) {
extra = `<span class="muted" style="color: var(--black-alpha-56);">主题:</span><span style="font-size: 13px;">${esc(state.themeText)}</span>`;
} else if (s.id === 'manual') {
extra = `<span class="muted" style="color: var(--black-alpha-56);">脚本:</span><span style="font-size: 13px;">${state.manualScript.length} 字</span>`;
} else {
extra = `<span class="mono" style="font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em;">// 走向由 Step 3 决定</span>`;
}
body = `<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<span class="pill info" style="display:inline-flex; align-items:center; gap:6px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); font-weight: 500;"><span class="dot" style="width:6px;height:6px;border-radius:50%;background:currentColor;"></span>${esc(s.name)}</span>
${extra}
</div>`;
} else {
body = '<span class="mono" style="color: var(--black-alpha-48); font-size: 11.5px;">未选择</span>';
}
}
return `<div class="wiz-pane collapsed">
<div class="wiz-pane-h">
<h3>${esc(title)}</h3>
<span style="flex:1"></span>
<button class="btn btn-ghost btn-sm" onclick="_wiz.jumpTo(${n})">修改</button>
</div>
${body}
</div>`;
}
function renderFoot() {
const cfg = railConfig();
const last = state.currentStep === 4;
const passOk = state.currentStep === 1 ? canPass1()
: state.currentStep === 2 ? canPass2()
: state.currentStep === 3 ? canPass3()
: canFinish();
const nextLabel = last ? '开始生成 →' : '下一步 →';
const hint = last
? `// 扣款 ¥${getCost().total.toFixed(2)} · 进入 pipeline`
: `// 下一步:${cfg[state.currentStep].label}`;
const action = last ? '_wiz.startGenerate()' : '_wiz.goNext()';
return `<div class="wiz-foot">
<button class="btn btn-ghost"${state.currentStep === 1 ? ' disabled' : ''} onclick="_wiz.goPrev()">← 上一步</button>
<div style="display:flex; align-items:center; gap:12px;">
<span class="mono" style="font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em;">${esc(hint)}</span>
<button class="btn btn-primary btn-lg${!passOk ? ' disabled' : ''}" onclick="${action}">${nextLabel}</button>
</div>
</div>`;
}
function renderPreview() {
const p = getProduct(), personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle(), c = getCost();
const shots = getShots();
const pointsOn = Object.entries(state.points).filter(([, v]) => v).map(([k]) => k);
const title = state.projectName || (p ? p.name + ' · 待命名' : '未命名项目');
const arrows = '<span class="arrow">' + ICONS.arrow + '</span>';
const productSection = p ? `
<div class="pv-section">
<div class="lbl">// 商品</div>
<ul class="pv-list">
<li>${esc(p.name)}</li>
<li>${esc(p.cat)}${p.price != null ? ' · ¥' + p.price : ''}</li>
</ul>
</div>
<div class="pv-section">
<div class="lbl">// 人设 · 风格</div>
<ul class="pv-list">
<li>${esc(personaObj.name)} · ${esc(personaObj.sub)}</li>
<li>${esc(styleObj.name)} · ${esc(durObj.tag)}</li>
</ul>
</div>
<div class="pv-section">
<div class="lbl">// 脚本走向</div>
<div class="pv-flow">
${styleObj.flow.map((n, i) => `<span style="display:inline-flex; align-items:center;"><span class="node">${esc(n)}</span>${i < styleObj.flow.length - 1 ? arrows : ''}</span>`).join('')}
</div>
</div>
<div class="pv-section">
<div class="lbl">// 突出卖点</div>
<ul class="pv-list">
${pointsOn.length ? pointsOn.map(k => `<li>${esc(k)}</li>`).join('') : '<li style="color: var(--black-alpha-48);">未选 · 由 LLM 自动权衡</li>'}
</ul>
</div>
` : `
<div class="pv-section">
<div class="lbl">// 待选择</div>
<ul class="pv-list" style="opacity: .6;">
<li style="color: var(--black-alpha-48);">先选一个商品</li>
<li style="color: var(--black-alpha-48);">预估指标会自动填充</li>
</ul>
</div>
`;
const footState = state.currentStep < 4 ? '进行中'
: canFinish() ? '就绪'
: (balanceAfter() < 5 ? '余额不足' : '待确认');
$('#preview').innerHTML = `
<div class="pv-h"><span>实时预估</span><span class="live">LIVE</span></div>
<div class="pv-title">${esc(title)}</div>
<div class="pv-metrics">
<div class="pv-metric"><div class="l">镜头</div><div class="v">${shots}<small></small></div></div>
<div class="pv-metric accent"><div class="l">预估完播</div><div class="v">${durObj.completion}<small>%</small></div></div>
<div class="pv-metric"><div class="l">预估转化</div><div class="v">${durObj.conversion}<small>%</small></div></div>
<div class="pv-metric"><div class="l">预估成本</div><div class="v">¥${c.total.toFixed(2)}</div></div>
</div>
${productSection}
<div class="pv-foot">
<span>Step ${state.currentStep} / 4 · Restraint</span>
<strong>${footState}</strong>
</div>
`;
}
/* ---------- partial updates (to keep inputs from losing focus) ---------- */
function renderStep1Only() {
// when user types in search or clicks cat chip — only re-render Step 1 main area
if (state.currentStep !== 1) return;
const body = $('#wiz-body');
const active = body.querySelector('.wiz-pane.active');
if (active) {
const tmp = document.createElement('div');
tmp.innerHTML = renderStep1();
active.replaceWith(tmp.firstElementChild);
}
// refocus search input
const inp = body.querySelector('.search-input input');
if (inp && document.activeElement !== inp) {
inp.focus();
const v = inp.value;
inp.setSelectionRange(v.length, v.length);
}
}
function updatePreviewLive() { renderPreview(); }
function updateFootOnly() {
const body = $('#wiz-body');
const foot = body.querySelector('.wiz-foot');
if (foot) {
const tmp = document.createElement('div');
tmp.innerHTML = renderFoot();
foot.replaceWith(tmp.firstElementChild);
}
}
function updateRailOnly() { renderRail(); }
/* ---------- main render ---------- */
function render() {
renderRail();
const body = $('#wiz-body');
let html = '';
if (state.currentStep >= 2) html += renderCollapsedStep(1);
if (state.currentStep >= 3) html += renderCollapsedStep(2);
if (state.currentStep === 1) html += renderStep1();
else if (state.currentStep === 2) html += renderStep2();
else if (state.currentStep === 3) html += renderStep3();
else html += renderStep4();
html += renderFoot();
body.innerHTML = html;
renderPreview();
}
// initial render
render();
})();
</script>
</body>
</html>

702
core/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,702 @@
# AirShelf 技术架构方案
> 版本v1.0
> 日期2026-05-29
> 定位:从原型走向真实可运营产品的顶层技术架构决策文档
> 适用范围Django 后端、前端产品化、火山 ARK AI 接入、额度账本、运营后台、60s 多段视频生产
---
## 1. 架构结论
AirShelf 不应该先横向补齐所有页面,而应该先打穿一条真实生产闭环:
商品创建 -> 项目创建 -> AI 脚本 -> 基础资产 -> 可选故事板 -> 4 段视频生成 -> FFmpeg 拼接导出 -> 额度确认扣费 -> 资产入库 -> 运营后台可观测
第一阶段采用“模块化单体 + 异步任务”的架构:
- 后端Django + Django REST Framework
- 数据库MySQL
- 缓存/队列/锁Redis
- 异步任务Celery Worker + Celery Beat
- 文件存储:火山 TOS
- AI 模型:火山 ARK统一 Provider 抽象
- 运营后台:先用 Django Admin + 少量自定义后台页
- 前端React + Vite 单页应用。以 `v1/*.html` 为核心视觉规格,`电商AI平台/*.html` 作为未迁移页面和原版能力补充,重建为真实前端应用
- 路由:正式业务入口使用 React History URL例如 `/products``/projects/new``/pipeline/:id``/exact/*.html` 只作为像素级设计稿镜像和视觉回归基线,不作为产品业务路由
暂不采用微服务。当前阶段最大风险不是服务边界不够细而是任务状态、扣费账本、AI 失败恢复、资产流转没有被一套一致的数据模型兜住。模块化单体更容易保证事务一致性,也更适合快速把 PRD 全量能力落地。
---
## 2. 核心原则
### 2.1 账本优先
额度系统不能后补。所有 AI 任务、导出任务、重跑任务都必须先经过额度预检,并由账本记录冻结、确认扣费、失败释放、人工调整。
关键原则:
- 失败不扣费
- 用户确认采用后扣费
- 预估消耗需要额度预检
- 扣费必须幂等
- 所有账务变更必须有流水
### 2.2 任务异步化
火山生图、生视频、视频拼接都不能放在同步 HTTP 请求里执行。API 只负责创建任务、返回 task_idWorker 负责执行、轮询、重试、写状态。
### 2.3 资产对象化
图片、视频、成片不直接存数据库。数据库只存 TOS object key、元数据、归属、状态、引用关系。所有中间产物都应成为可追踪 Asset。
### 2.4 状态机先行
项目、阶段、AI 任务、视频片段、导出任务都必须有清晰状态机。不要只靠布尔字段拼状态,否则 60s 多段生产会很快失控。
### 2.5 单项目多段并发
60s 视频按 4 段 x 15s 生产。每段是独立 VideoSegment 和独立 AIJob可并发、可单段失败、可单段重跑、可回选历史版本。
---
## 3. 系统拓扑
```text
Browser
|
| HTTPS
v
Frontend Web
|
| REST / SSE or WebSocket
v
Django API
|
| ORM
v
MySQL
Django API
|
| enqueue task
v
Redis broker
|
v
Celery Workers
| | |
| | +--> FFmpeg export
| +----------> TOS upload/download
+------------------> Volcano ARK
Celery Workers
|
| status / ledger / asset metadata
v
MySQL
Django Admin / Ops
|
v
MySQL + task logs + billing ledger
```
部署形态:
- `airshelf-web`:前端静态资源或 SSR 前端服务
- `airshelf-api`Django API
- `airshelf-worker-default`:通用任务
- `airshelf-worker-ai`AI 文本/图片/视频任务
- `airshelf-worker-media`FFmpeg 拼接、转码、缩略图
- `airshelf-beat`定时任务、超时扫描、TOS 临时文件清理
---
## 4. 应用模块划分
建议 Django apps
```text
apps/
accounts/ 用户、登录、JWT、团队成员
teams/ 团队、角色、邀请、权限
products/ 商品库、卖点、商品图
projects/ 项目、阶段、脚本、分镜
assets/ 资产库、TOS 文件、引用关系
ai/ 火山 Provider、AIJob、模型配置
pipeline/ 5 阶段编排、视频片段、故事板
billing/ 额度账户、冻结、扣费、流水、套餐
media/ FFmpeg 拼接、字幕、BGM、导出
ops/ 运营后台扩展、任务监控、财务对账
common/ 审计字段、软删除、幂等、锁、工具
```
模块边界:
- `ai` 不直接扣费,只上报任务结果和预估成本。
- `billing` 不调用火山,只处理额度、冻结、确认扣费和流水。
- `assets` 不理解业务阶段,只管理文件、资产类型、引用和权限。
- `pipeline` 负责把 PRD 的 5 个 Stage 串起来。
- `ops` 只读为主,人工调整必须写审计日志。
---
## 5. 关键数据模型
### 5.1 账户与团队
- `User`
- `Team`
- `TeamMember`
- `Invitation`
- `Role`
V1 决策:
- 一个用户默认属于一个团队。
- 注册自动创建团队,注册者为超管。
- 预留多团队字段,但 V1 不开放切换多团队。
### 5.2 商品与项目
- `Product`
- `ProductImage`
- `ProductSellingPoint`
- `Project`
- `ProjectStageState`
- `Script`
- `ScriptShot`
Project 关键字段:
- `team_id`
- `product_id`
- `creator_id`
- `target_duration_seconds`30 / 45 / 60
- `segment_count`2 / 3 / 4
- `current_stage`
- `status`
### 5.3 资产
- `Asset`
- `AssetVersion`
- `AssetReference`
资产类型:
- product_image
- product_triptych
- character_portrait
- character_triptych
- scene_image
- storyboard
- video_clip
- final_video
- bgm
- subtitle
关键字段:
- `team_id`
- `project_id`
- `owner_id`
- `tos_key`
- `mime_type`
- `duration_seconds`
- `width`
- `height`
- `source`
- `status`
- `is_shared`
### 5.4 AI 任务
- `AIJob`
- `AIJobAttempt`
- `ModelConfig`
AIJob 关键字段:
- `job_type`text / image / video
- `provider`volcengine
- `model_name`
- `request_payload`
- `response_payload`
- `external_task_id`
- `status`
- `progress`
- `error_code`
- `error_message`
- `estimated_cost`
- `actual_cost`
- `idempotency_key`
任务状态:
```text
created -> quota_checked -> queued -> submitted -> polling -> succeeded
-> failed
-> timeout
-> cancelled
```
### 5.5 视频片段与导出
- `VideoSegment`
- `VideoSegmentVersion`
- `ExportJob`
- `TimelineItem`
- `SubtitleCue`
VideoSegment
- `project_id`
- `segment_index`
- `start_second`
- `end_second`
- `prompt`
- `use_storyboard`
- `adopted_version_id`
- `status`
60s 项目生成 4 个 VideoSegment
- 0-15s
- 15-30s
- 30-45s
- 45-60s
### 5.6 额度与财务
- `Wallet`
- `QuotaPolicy`
- `QuotaUsage`
- `BillingTransaction`
- `BillingHold`
- `PricingRule`
- `RechargeOrder`
四层额度:
- 用户日额度
- 用户月额度
- 团队月额度
- 团队总额度池
账务动作:
- estimate
- hold
- release
- charge
- refund
- manual_adjust
所有扣费以 `BillingTransaction` 为准,不从任务表反推财务结果。
---
## 6. Redis 设计
Redis DB index
- DB 0Django cache
- DB 1Celery broker
- DB 2Celery result backend
- DB 3分布式锁、幂等锁、防重复扣费锁
- DB 4限流、验证码计数、短期风控
- DB 5任务进度 pubsub / WebSocket 预留
锁设计:
- `lock:billing:confirm:{job_id}`
- `lock:project:generate:{project_id}`
- `lock:segment:generate:{segment_id}`
- `lock:export:{project_id}`
锁必须有 TTL且所有关键写入仍要依赖数据库唯一约束保证最终幂等。
---
## 7. 火山 ARK Provider 设计
所有模型通过统一 Provider 调用:
```text
AIProvider
generate_text()
generate_image()
create_video_task()
get_video_task()
```
当前模型决策来自 `account.md`,代码只读取环境变量:
- 文本主模型DeepSeek-V3-2
- 文本备用模型Doubao Seed 2.0 Pro / Lite
- 图片模型Seedream 5.0 Lite / 5.0 / 4.5
- 视频模型Seedance 2.0 / 2.0 Fast / 1.5 Pro
接口策略:
- 文本OpenAI-compatible chat
- 图片:同步或短异步,统一落成 AIJob
- 视频:异步任务,提交后轮询
- 所有外部响应原文进入 `response_payload`,便于排障
模型配置不硬编码在业务流程中。业务流程只声明用途:
- script_generation
- asset_prompt_generation
- product_image_optimize
- storyboard_generation
- video_segment_generation
由 ModelConfig 决定具体模型。
---
## 8. 60s 多段生产流程
### 8.1 Stage 1 脚本
输入:
- 商品信息
- 卖点
- 目标时长
- 用户指令
输出:
- Script
- ScriptShot
- 自动切段结果
60s 输出要求:
- `segment_count = 4`
- 每段约 15s
- 每个镜头必须归属 segment_index
### 8.2 Stage 2 基础资产
生成:
- 商品三视图
- 人物立绘
- 人物三视图
- 场景图
资产候选规则:
- 创意选择型一次 4 张
- 结构转换型一次 1 张
- 采用后才进入当前项目引用
### 8.3 Stage 3 故事板
故事板是可选项,不是硬前置。
如果生成:
- 每段 1 张故事板图
- 60s 项目最多 4 张
- 每张可独立重跑
### 8.4 Stage 4 视频片段
每个 VideoSegment 独立生成:
- 输入:脚本分段、基础资产、可选故事板、视频提示词
- 输出VideoSegmentVersion
- 用户采用某一版后,才进入可拼接素材
并发策略:
- 单项目最多 4 段并发
- 全局并发由 Worker 数和 Redis 队列控制
- 外部配额不足时降级到每项目 2 段并发
### 8.5 Stage 5 拼接导出
输入:
- 已采用的视频片段
- 时间线配置
- 字幕
- BGM
- 转场
输出:
- final_video Asset
- ExportJob
第一版导出能力:
- 单主轨
- 排序
- 裁剪
- 字幕烧录
- BGM 混音
- 9:16
- 1080P MP4
---
## 9. API 设计原则
API 应按资源和动作拆分,不把复杂动作塞进一个“大生成接口”。
示例:
```text
POST /api/products/
GET /api/products/
POST /api/projects/
GET /api/projects/{id}/
POST /api/projects/{id}/script/generate/
POST /api/projects/{id}/script/confirm/
POST /api/projects/{id}/assets/generate/
POST /api/assets/{id}/adopt/
POST /api/projects/{id}/storyboards/generate/
POST /api/storyboards/{id}/adopt/
POST /api/video-segments/{id}/generate/
POST /api/video-segment-versions/{id}/adopt/
POST /api/projects/{id}/exports/
GET /api/exports/{id}/
GET /api/ai-jobs/{id}/
POST /api/billing/estimate/
GET /api/billing/transactions/
```
前端轮询策略:
- AIJob 详情接口提供统一进度。
- Stage 页面不直接轮询火山。
- 后续可用 SSE/WebSocket 替代轮询。
---
## 10. 运营后台
第一版用 Django Admin 承担运营后台,不另起复杂后台前端。
必须有:
- 用户管理
- 团队管理
- 额度账户
- 消费流水
- AIJob 任务监控
- 视频片段与导出任务
- 模型配置
- PricingRule
- 人工补偿/退款/额度调整
人工操作要求:
- 必须写审计日志
- 财务调整必须写 BillingTransaction
- 禁止直接改余额字段绕过账本
---
## 11. 部署与环境
环境:
- local
- test
- production
敏感配置:
- 本地测试凭据记录在 `account.md`
- 代码与架构文档不保存真实密钥
- K8s 使用 Secret 注入环境变量
K8s 工作负载:
```text
Deployment airshelf-web
Deployment airshelf-api
Deployment airshelf-worker-default
Deployment airshelf-worker-ai
Deployment airshelf-worker-media
Deployment airshelf-beat
Service airshelf-web
Service airshelf-api
Ingress airshelf
Secret airshelf-env
ConfigMap airshelf-config
```
CI/CD 需要从当前纯静态部署升级为多镜像构建:
- web image
- api image
- worker image 可复用 api image启动命令不同
---
## 12. 可观测性
日志:
- API request log
- AI provider request/response summary
- Celery task log
- billing ledger log
- export job log
指标:
- AI 任务成功率
- AI 平均耗时
- 视频段失败率
- 导出失败率
- 队列长度
- Worker 并发
- TOS 上传失败率
- 额度冻结未释放数量
告警:
- AI 任务连续失败
- 队列堆积
- 导出任务超时
- Billing hold 超时未释放
- Redis / MySQL 不可用
---
## 13. 安全与权限
权限模型:
- 超管:团队所有权限、充值、额度划拨、财务查看
- 团管:成员管理、成员额度分配、团队资产管理
- 成员:创建项目、使用额度、管理自己的项目
安全要求:
- 所有 API 必须按 team_id 做数据隔离
- 资产下载使用签名 URL
- 上传文件做类型、大小、时长校验
- 后台人工操作写审计
- ARK/TOS/Redis/MySQL 密钥只走环境变量
- JWT refresh token 需要轮换和黑名单
---
## 14. 关键风险与架构应对
| 风险 | 应对 |
| --- | --- |
| PRD 60s 与页面流程 15s 口径冲突 | 以 60s 多段为工程目标,页面文案后续统一 |
| AI 任务失败或超时 | AIJob 状态机 + Attempt + 重试 + 单段重跑 |
| 重复扣费 | BillingHold + 幂等 key + Redis lock + DB 唯一约束 |
| 外部模型并发不足 | 队列限流,单项目并发可降级 |
| TOS 文件失控增长 | tmp 前缀清理任务,资产软删除,引用检查 |
| 视频导出耗时长 | media worker 独立队列,任务进度入库 |
| 运营后台需求膨胀 | V1 先 Django Admin后续再独立后台 |
---
## 15. 开发路线
### Phase 0工程初始化
- 创建 Django 项目
- 配置 MySQL / Redis / Celery / TOS
- Dockerfile 与 K8s 基础部署
- 健康检查与环境变量管理
验收:
- API 可启动
- Worker 可启动
- 能连接 MySQL / Redis
- 能上传测试文件到 TOS
### Phase 1业务地基
- 用户、团队、角色
- 商品库
- 项目
- 资产模型
- AIJob
- Billing 账本
验收:
- 注册自动建团队
- 商品 CRUD
- 项目创建
- 额度预检、冻结、释放、确认扣费可跑通
### Phase 2AI 纵向闭环
- 脚本生成
- 基础资产生成
- 故事板生成
- 4 段视频生成
- 结果入 TOS 和资产库
验收:
- 一个 60s 项目可生成 4 个视频片段
- 单段失败可重跑
- 用户采用后扣费
### Phase 3导出与前端联调
- FFmpeg 拼接
- 字幕
- BGM
- 1080P MP4 导出
- 前端接真实 API
验收:
- 4 段视频可导出成 60s 成片
- 成片入库
- 可下载、可预览
### Phase 4运营后台与上线硬化
- Django Admin 增强
- 任务监控
- 财务对账
- 模型配置
- 日志告警
- 并发压测
验收:
- 运营能查任务、查用户、查流水、人工调整额度
- 失败任务可定位
- 队列堆积可观测
---
## 16. 最终判断
AirShelf 的架构核心不是“页面数量”而是“AI 生产系统 + 账本系统 + 资产系统”的一致性。
正确的第一目标是:
> 用 Django + Celery + TOS + 火山 ARK 打穿真实 60s 多段视频生产闭环,并保证失败恢复和扣费一致性。
页面可以逐步接入,运营后台可以先用 Django Admin但账本、任务状态机、资产引用和 AI Provider 必须从第一天按真实产品设计。

View File

@ -0,0 +1,56 @@
# AirShelf 原型还原核对记录
更新时间2026-05-29
## 核对结论
当前 React 前端已按 `v1``电商AI平台` 原型补回主页面、隐藏子页面、按钮跳转和弹窗状态。真实生视频测试不在本轮执行,必须等页面还原、非视频接口、额度和运营后台能力都完成后再最后测试。
## 页面对齐矩阵
| 原型页面 | React 路由 | 状态 |
| --- | --- | --- |
| `v1/index.html` | `#dashboard` | 已接入 |
| `v1/products.html` | `#products` | 已接入 |
| `电商AI平台/product-create-upload.html` | `#productCreateUpload` | 已补齐 |
| `电商AI平台/product-detail.html` | `#productDetail` | 已补齐 |
| `v1/projects.html` | `#projects` | 已接入列表/网格/删除确认 |
| `v1/projects-new.html` | `#projectWizard` | 已补齐 |
| `v1/pipeline.html` | `#pipeline` | 已接入 5 Stage 自由切换 |
| `v1/library.html` | `#library` | 已接入上传抽屉 |
| `电商AI平台/account.html` | `#account` | 已补齐充值弹窗和 4 类消费 Tab |
| `电商AI平台/team.html` | `#team` | 已补齐成员表、权限、额度、创建/编辑/重置/动态弹窗 |
| `电商AI平台/messages.html` | `#messages` | 已补齐收件箱、详情、处理记录、动作跳转 |
| `电商AI平台/settings.html` | `#settings`, `#settingsNotify` | 已补齐多分区与通知入口 |
| `电商AI平台/asset-factory.html` | `#assetFactory` | 已补齐三类工具入口和任务中心 |
| `电商AI平台/image-optimize.html` | `#imageOptimize` | 已补齐工作台 |
| `电商AI平台/model-photo.html` | `#modelPhoto` | 已补齐工作台 |
| `电商AI平台/model-photo-demo-a.html` | `#modelPhotoDemoA` | 已补齐 |
| `电商AI平台/model-photo-demo-b.html` | `#modelPhotoDemoB` | 已补齐 |
| `电商AI平台/platform-cover.html` | `#platformCover` | 已补齐工作台 |
## 非视频接口对接
已对接:
- Auth注册、登录、退出、当前用户。
- 商品:列表、详情、创建、更新、删除。
- 项目:列表、创建、删除、流水线阶段操作。
- 资产:列表、上传。
- 计费:余额摘要、账本流水。
- 团队:成员只读列表。
- AI模型配置列表、AI 任务列表。
仍需后端补写接口后再做真实对接:
- 团队后台写操作:创建成员、编辑成员额度、重置密码、团队月限额。
- 支付/充值:支付订单创建、支付状态回调、人工入账。
- 图片工具真实生产接口:商品图、模特图、平台套图的独立任务创建、轮询、采用入库。
- 运营后台:用户、团队、任务、账单、模型、异常任务管理。
## 验收原则
1. 先验收页面还原和点击流。
2. 再验收非视频接口、额度、账本和后台管理。
3. 最后才测试真实生视频,包括 60s 多段生产、轮询、采用、导出和扣费回滚。

View File

@ -0,0 +1,734 @@
# AirShelf 功能开发对齐文档
> 版本v0.3
> 日期2026-05-29
> 目的:把 PRD、页面流程定稿、v1 原型、原版补充页面和后端架构统一成一份可开发、可验收、可排期的功能清单,避免实现时遗漏或误用过时逻辑。
## 1. 对齐源优先级
实现时按下面顺序判断,不一致时以上层为准:
| 优先级 | 来源 | 用途 |
| --- | --- | --- |
| 1 | `PRD.md` | 业务范围、V1/V2 边界、核心规则、验收目标 |
| 2 | `电商AI平台/页面流程定稿.md` | 页面流转、交互边界、阶段拆分、哪些能力不做 |
| 3 | `v1/*.html` | 当前核心原型,是在原版基础上的修改和增加;已覆盖页面以 v1 为准 |
| 4 | `电商AI平台/*.html` | 原版完整原型;用于补齐 v1 未覆盖页面、设计系统、登录注册、团队、设置和图片工具 |
| 5 | `core/ARCHITECTURE.md` | 后端架构、任务编排、额度、存储、部署策略 |
| 6 | `account.md` 与环境变量 | 测试资源与真实配置来源,不进入代码和公开文档 |
## 2. 当前必须统一的产品口径
| 口径 | 开发结论 |
| --- | --- |
| 后端技术 | Django + DRF + CeleryMySQL 持久化Redis 做缓存/任务/锁TOS 存储文件。 |
| AI 服务 | 全部接火山服务:对话、文生图/图生图、生视频、可能的脚本优化和素材理解。 |
| 60s 生成 | V1 真实实现 `4 x 15s` 多段视频,再进入 Stage5 拼接导出。每段独立任务、独立状态、独立重跑、独立额度记录。 |
| 项目新建 | 新建项目页只选商品和可选项目名。脚本来源、卖点、风格、时长等放到 Stage1。 |
| 分镜 | Stage3 是可选增强能力,不是生成视频的硬阻塞。无分镜时 Stage4 仍可用脚本和基础资产生成视频。 |
| 用户上传视频 | 不进入 Stage4。上传视频只作为 Stage5 的素材池,可用于替换、补位、剪辑和导出。 |
| 额度扣费 | 所有 AI 调用必须先预估、确认、冻结或记账;失败不实际扣费;成功落账可追溯。 |
| 运营后台 | V1 全量真实实现时必须有后台:用户/团队/项目/任务/资产/额度/账单/模型配置/异常重试。 |
| 原型取舍 | 已迁移到 `v1` 的页面以 `v1` 为准;`v1` 未覆盖的页面回看原版。复制项目、归档、批量生成、审核流、爆款复刻、直接发布等仍不进入 V1 主路径,除非 PRD 明确升级。 |
## 3. V1 功能总目标
V1 的目标不是只做静态界面,而是跑通一条真实可计费、可追踪、可运营的 AI 视频生产链路:
1. 用户注册并自动创建团队。
2. 团队管理员配置成员、月度额度、充值或分配额度。
3. 用户创建商品,维护商品图、卖点、品牌、类目等基础资料。
4. 用户基于商品创建项目。
5. 项目进入 5 阶段生产管线:脚本、基础资产、故事板、视频片段、拼接导出。
6. 真实调用火山模型完成脚本、图片、视频生产。
7. 60s 视频按 4 个 15s 段落并发/排队生成,支持失败重试和单段重跑。
8. Stage5 支持轻量剪辑、字幕、BGM、转场、导出 9:16 1080p MP4。
9. 所有产物自动入资产库,支持搜索、筛选、下载、复用。
10. 所有消耗进入额度账本,团队、成员、项目、任务都能追溯。
11. 运营后台可介入排障、补额度、重试任务、管理模型和查看成本。
## 4. 页面与功能地图
| 页面 | 原型文件 | 后端模块 | 优先级 | 必须实现 |
| --- | --- | --- | --- | --- |
| 登录/注册 | `电商AI平台/login.html`, `电商AI平台/register.html` | `accounts`, `teams` | P0 | 注册、登录、登出、创建团队、JWT/Session、基础权限。 |
| 工作台 | `v1/index.html` | `projects`, `billing`, `assets` | P1 | 项目概览、待处理、最近资产、额度摘要、快速入口。 |
| 商品库 | `v1/products.html` | `products` | P0 | 商品列表、搜索筛选、网格/列表、创建、编辑、删除、详情跳转。 |
| 商品创建 | `电商AI平台/product-create*.html` | `products`, `assets` | P0 | 图片上传、商品信息、卖点、类目、素材绑定、校验。 |
| 商品详情 | `电商AI平台/product-detail.html` | `products`, `assets`, `projects` | P1 | 商品资料、关联素材、关联项目、可编辑。 |
| 项目列表 | `v1/projects.html` | `projects`, `tasks` | P0 | 状态筛选、搜索、排序、项目卡片/列表、继续/查看/重试。 |
| 新建项目 | `v1/projects-new.html` | `projects`, `products` | P0 | 只选择商品与项目名,创建后进入 Stage1。 |
| 生产管线 | `v1/pipeline.html` | `projects`, `tasks`, `ai`, `assets`, `billing` | P0 | 5 阶段完整闭环,真实 AI 调用,状态机和任务编排。 |
| 资产库 | `v1/library.html` | `assets`, `storage` | P0 | 素材分类、搜索、筛选、上传、下载、删除、复用。 |
| 消费/账单 | `v1/account.html` | `billing`, `teams` | P0 | 余额、充值记录、项目账单、成员账单、额度规则、流水。 |
| 团队管理 | `电商AI平台/team.html` | `teams`, `accounts`, `billing` | P1 | 成员、角色、邀请、禁用、月度额度、权限矩阵。 |
| 运营后台 | 无独立静态原型,按 PRD 和架构补齐 | `ops`/Django Admin | P0 | 用户团队、任务、资产、账单、模型配置、失败重试、人工补偿。 |
| 设置/消息 | `电商AI平台/settings.html`, `电商AI平台/messages.html` | `accounts`, `notifications` | P2 | 账户设置、消息中心、通知状态。 |
| 图片工具 | `电商AI平台/asset-factory.html` 等 | `ai`, `assets` | P2 | 可作为后续独立工具,不影响主生产闭环。 |
## 5. P0 主生产闭环
### 5.1 账号与团队
前端需要:
- 注册页、登录页、登出入口。
- 当前用户信息、当前团队信息、当前角色展示。
- 未登录拦截,登录后回跳原目标页。
- 团队额度不足、权限不足、账号禁用等通用提示。
后端需要:
- 用户模型、团队模型、团队成员模型、角色权限。
- 注册时自动创建默认团队,并把注册用户设为团队管理员。
- 登录认证、刷新 token 或 session、密码重置预留。
- 权限中间件:团队隔离、角色能力、资源归属校验。
验收标准:
- 新用户注册后能进入工作台并拥有一个默认团队。
- 同一团队成员能看到团队资源,不同团队不能互相访问。
- 非管理员不能进行充值、成员额度配置、后台敏感操作。
### 5.2 商品库
前端需要:
- 商品列表:网格/列表切换、搜索、类目筛选、时间筛选、排序。
- 商品卡片:封面图、名称、类目、素材数量、关联视频数量、最近更新时间。
- 商品创建/编辑:商品图、标题、品牌、类目、卖点、规格、目标人群、备注。
- 图片上传:本地上传到 TOS生成可预览 asset。
- 删除保护:有关联项目时给出风险提示。
后端需要:
- `Product``ProductImage``ProductSellingPoint` 等模型。
- 商品 CRUD API、筛选分页 API。
- TOS 上传签名或后端中转上传。
- 商品与资产、项目的关联关系。
验收标准:
- 商品能创建、编辑、查询、删除。
- 项目创建时只能选择当前团队可用商品。
- 商品图片能进入资产库,并可在 Stage2 作为商品基础资产使用。
### 5.3 项目列表与新建
前端需要:
- 项目列表 tabs全部、进行中、生成中、已完成、失败。
- 项目搜索、筛选、排序。
- 项目卡片展示9:16 封面、商品、当前阶段、5 阶段进度、状态、最近更新时间。
- 操作:继续、查看、失败重试。
- 新建项目只选择商品和可选项目名,不放脚本来源、风格、时长等高级配置。
后端需要:
- `Project``ProjectStageState``ProjectProgress`
- 项目状态机:草稿、脚本中、资产中、分镜中、视频中、导出中、完成、失败。
- 项目创建 API、项目列表 API、项目详情 API、阶段推进 API。
验收标准:
- 从商品创建项目后进入 Stage1。
- 项目当前阶段和每阶段状态能准确回显。
- 失败项目能看到失败原因,并能按任务粒度重试。
### 5.4 Stage1 脚本
前端需要:
- 中区脚本/读秒分镜工作台。
- 右侧 AI 对话、历史版本。
- 底部 AI 输入框。
- 脚本来源入口AI 帮我写、我有脚本、一句话生成、复刻爆款入口置灰或隐藏。
- 商品卖点选择在 Stage1 完成。
- 脚本版本:生成、编辑、保存、采用、回滚。
后端需要:
- `ScriptVersion``ScriptSegment`
- 火山对话/文本模型适配器。
- Prompt 模板管理:商品信息、卖点、平台风格、时长目标、脚本结构。
- 脚本生成任务、脚本优化任务、版本记录。
- 消耗预估和账本记录。
验收标准:
- 可从商品信息生成脚本。
- 可手工粘贴脚本并结构化成多个段落。
- 可保存多个版本,采用一个版本进入 Stage2。
- AI 失败不扣费,并可重试。
### 5.5 Stage2 基础资产
前端需要:
- 三类资产顺序:商品、人物、场景。
- 商品资产:商品三联图为一张 16:9 图片,不拆成 3 个独立槽位。
- 人物资产AI 提取人物、生成 4 张候选肖像、选择 1 张、生成 16:9 三联图、采用。
- 场景资产:生成 4 张候选场景图、选择 1 张、采用。
- 支持重跑、版本历史、采用、预览。
- 人物资产可选择保存到人物库。
后端需要:
- `BaseAssetGroup``AssetVersion``AssetSelection`
- 火山生图/图生图模型适配器。
- 图片任务队列、并发限制、失败重试。
- 资产与项目阶段绑定。
- TOS 存储、缩略图、元数据。
验收标准:
- 三类基础资产都能真实生成并保存。
- 每次重跑保留历史,不覆盖已采用版本。
- Stage2 至少采用商品、人物、场景各一组后,可进入 Stage3 或跳过分镜进入 Stage4。
### 5.6 Stage3 故事板
前端需要:
- 故事板为可选阶段。
- 支持按脚本段落生成故事板图。
- Prompt 可编辑,但不做复杂聊天。
- 显示绑定资产标签:商品、人物、场景。
- 当基础资产变更时,提示建议重新生成故事板。
- 支持跳过故事板直接进入 Stage4。
后端需要:
- `StoryboardVersion``StoryboardFrame`
- 脚本段落到故事板帧的映射。
- 火山图片模型任务。
- 故事板版本、采用、重跑。
验收标准:
- 有故事板时Stage4 默认使用故事板作为视频生成参考。
- 无故事板时Stage4 能基于脚本和基础资产生成视频。
- 重跑故事板不影响已采用的视频片段,除非用户主动重新生成视频。
### 5.7 Stage4 视频片段
前端需要:
- 展示 4 个 15s 片段槽位,对应 60s 总视频。
- 每段显示状态:未开始、排队、生成中、成功、失败、已采用。
- 每段可编辑 prompt、生成、重跑、查看历史、采用。
- 支持并发生成,但前端必须展示每段独立进度。
- 单次生成一个候选视频,历史版本中保留多次结果。
- 用户上传视频不出现在 Stage4。
后端需要:
- `VideoSegment``VideoSegmentVersion`
- 火山生视频模型适配器。
- Celery 任务:提交、轮询、下载、转存 TOS、回调处理。
- 任务幂等:同一段重试不产生重复扣费。
- 并发控制:团队级、用户级、模型级、全局级。
- 失败原因标准化:额度、模型、超时、内容安全、存储、未知。
验收标准:
- 60s 视频能由 4 段 15s 视频构成。
- 某一段失败时,不影响其他已成功段;可单段重跑。
- 每段成功后自动入资产库,并能被 Stage5 使用。
- 模型失败不实际扣费;模型成功但后处理失败要进入可补偿状态。
### 5.8 Stage5 拼接导出
前端需要:
- 轻量剪辑器素材池、主轨道、字幕、BGM、转场、预览、导出。
- 素材池包含 AI 视频片段、用户上传视频、资产库视频。
- 单主轨,支持自动放置、拖拽排序、删除、替换、裁剪。
- 支持从脚本生成字幕,并允许编辑样式和文本。
- 支持 BGM 选择、音量设置。
- 导出结果页:预览、下载、复制链接、查看资产、继续编辑、返回项目。
后端需要:
- `Timeline``TimelineClip``SubtitleTrack``BgmTrack``ExportJob`
- FFmpeg 拼接、转码、字幕烧录或外挂字幕策略。
- 输出规格9:16、1080p、MP4。
- 导出任务队列、进度、失败重试。
- 导出文件转存 TOS生成资产库记录。
验收标准:
- 4 段视频能拼接成一个 60s 成片。
- 用户上传素材能加入 Stage5 并参与导出。
- 导出成功后自动进入资产库的成片分类。
- 导出失败能保留 timeline 并允许重试。
## 6. P0 额度与账本
前端需要:
- 生成前显示本次预估消耗和账户余额。
- 额度不足时阻止提交,并给管理员充值或申请额度入口。
- 账单页按项目、成员、账单维度查看消耗。
- 团队页可配置成员月度额度。
后端需要:
- 四层额度:团队余额、成员月度额度、项目预算、单任务预估/实扣。
- `CreditAccount``CreditLedger``QuotaPolicy``CreditReservation`
- 账本必须只追加,不直接覆盖历史。
- 支持预估、冻结、成功扣费、失败释放、人工调整。
- 每条账本关联:团队、用户、项目、任务、模型、输入输出资产。
验收标准:
- 所有 AI 与导出任务都有账本记录。
- 失败任务不会消耗最终余额。
- 管理员能看到团队、成员、项目三个维度的消耗。
- 后台人工补偿、充值、扣减都可追溯。
## 7. P0 资产库与存储
前端需要:
- Tabs人物、场景、商品图、成片、我的上传、未分类。
- 搜索、类型筛选、来源筛选、排序。
- 上传素材、预览、下载、删除。
- 在项目中复用资产。
后端需要:
- `Asset``AssetFile``AssetTag``AssetUsage`
- 文件类型image、video、audio、subtitle、document。
- 来源upload、ai_generated、exported、system。
- TOS object key、bucket、content type、size、duration、resolution、checksum。
- 缩略图、预览地址、临时下载链接。
验收标准:
- AI 生成图、视频片段、最终成片都自动入库。
- 用户上传文件可用于 Stage5。
- 资产被项目使用时有使用记录,删除时能提示风险。
## 8. P0 运营后台
运营后台可以先基于 Django Admin 扩展后续再做独立后台页面。V1 全量真实 AI 必须具备这些能力:
| 模块 | 必须能力 |
| --- | --- |
| 用户与团队 | 查询、禁用、角色、团队归属、成员额度。 |
| 商品与项目 | 查询、状态查看、异常项目定位。 |
| AI 任务 | 查看入参摘要、模型、状态、耗时、失败原因、重试、取消。 |
| 资产 | 查看文件、归属、来源、TOS key、可用性检查。 |
| 账本 | 充值、扣费、释放、人工补偿、流水审计。 |
| 模型配置 | 模型 endpoint、能力类型、单价、限流、开关、降级策略。 |
| 系统配置 | Prompt 模板、BGM 库、字幕样式、导出参数。 |
验收标准:
- 任一用户反馈“生成失败”时,运营能在后台定位到项目、阶段、任务、模型错误和账本状态。
- 后台能安全重试任务,不产生重复扣费。
- 模型临时不可用时能关闭入口或切换备用配置。
## 9. 状态机与任务状态
项目阶段状态:
| 状态 | 含义 |
| --- | --- |
| `not_started` | 还未进入阶段。 |
| `draft` | 有编辑内容,但未确认提交。 |
| `queued` | 已进入队列。 |
| `running` | 正在执行。 |
| `succeeded` | 当前阶段已完成。 |
| `failed` | 当前阶段失败,可查看原因。 |
| `skipped` | 用户主动跳过,比如 Stage3。 |
| `needs_review` | 任务成功但需要用户选择或确认采用。 |
AI 任务状态:
| 状态 | 含义 |
| --- | --- |
| `created` | 任务已创建但未扣/未冻结。 |
| `reserved` | 已冻结或记录预估额度。 |
| `submitted` | 已提交火山。 |
| `polling` | 等待火山结果。 |
| `postprocessing` | 下载、转存、转码、生成缩略图。 |
| `succeeded` | 完成并落资产。 |
| `failed` | 失败并释放额度。 |
| `compensating` | 外部成功但本地后处理失败,需要补偿。 |
| `cancelled` | 用户或系统取消。 |
状态要求:
- 前端所有按钮必须根据状态禁用或显示正确动作。
- 后端所有阶段推进必须校验前置条件。
- Celery 任务必须幂等,重复执行不会重复创建资产或重复扣费。
## 10. API 开发清单
建议先按下面 API 分组开发,保证前后端可以并行:
| 分组 | API 能力 |
| --- | --- |
| Auth | 注册、登录、登出、当前用户、当前团队。 |
| Team | 团队详情、成员列表、邀请、禁用、角色、月度额度。 |
| Product | 商品 CRUD、图片上传、卖点、详情、关联项目。 |
| Project | 项目 CRUD、列表筛选、详情、阶段状态、失败重试。 |
| Script | 生成脚本、优化脚本、保存版本、采用版本。 |
| Base Assets | 生成商品图/人物/场景、候选列表、采用、重跑。 |
| Storyboard | 生成故事板、编辑 prompt、采用、跳过。 |
| Video | 生成片段、查询进度、重跑、采用、历史版本。 |
| Timeline | 素材池、轨道、字幕、BGM、保存 timeline。 |
| Export | 提交导出、查询进度、下载、复制链接、重试。 |
| Asset | 资产列表、筛选、上传、预览、下载、删除、复用。 |
| Billing | 余额、预估、确认、流水、账单筛选、人工调整。 |
| Ops | 任务查询、模型配置、重试、补偿、系统配置。 |
## 11. 数据模型开发清单
P0 最小模型集合:
- `User`
- `Team`
- `TeamMember`
- `Role`
- `Product`
- `ProductImage`
- `ProductSellingPoint`
- `Project`
- `ProjectStage`
- `ScriptVersion`
- `ScriptSegment`
- `Asset`
- `AssetFile`
- `AssetUsage`
- `BaseAssetGroup`
- `StoryboardVersion`
- `StoryboardFrame`
- `VideoSegment`
- `VideoSegmentVersion`
- `Timeline`
- `TimelineClip`
- `SubtitleTrack`
- `BgmTrack`
- `ExportJob`
- `AITask`
- `ModelProvider`
- `ModelConfig`
- `CreditAccount`
- `CreditLedger`
- `CreditReservation`
- `QuotaPolicy`
关键约束:
- 所有业务表必须带 `team_id`,必要时带 `created_by`
- 所有 AI 产物必须能追溯到 `project_id``task_id``model_config_id`
- 所有费用必须通过账本记录,不直接修改余额后丢失原因。
- 所有 TOS 文件必须有资产记录或临时文件清理机制。
## 12. 前端实现清单
前端不应该直接复制静态 HTML而应该抽象为真实组件
正式产品入口必须是 React History 路由,不使用 `*.html` 作为业务页面地址。当前 `/exact/*.html` 的用途是保存设计稿镜像、跑像素级视觉回归和子页面校对;真实页面使用 `/login``/register``/dashboard``/products``/products/new``/products/:id``/projects``/projects/new``/pipeline/:id` 等 React 路由。
| 组件/区域 | 开发要求 |
| --- | --- |
| App Shell | 顶部/侧边导航、当前团队、用户菜单、未读消息、额度提示。 |
| Resource Picker | 商品选择、资产选择、项目素材选择复用同一交互。 |
| Stage Stepper | 5 阶段进度,支持点击已完成阶段回看。 |
| Task Progress | 轮询任务状态,显示排队、进度、失败原因、重试。 |
| Version Panel | 脚本、图片、故事板、视频都复用版本历史/采用模式。 |
| Quota Modal | 生成前统一确认额度,不在每个页面重复造逻辑。 |
| Upload Widget | 图片/视频/音频上传到 TOS支持进度和失败重试。 |
| Asset Card | 资产预览、类型、来源、下载、删除、复用。 |
| Timeline Editor | Stage5 主轨、素材池、字幕、BGM、导出。 |
| Empty/Error States | 首次使用、无数据、额度不足、模型失败、网络失败。 |
原型差异注意:
- 已迁移到 `v1` 的页面,视觉与信息层级优先参考 `v1`
- `v1` 未覆盖的登录注册、商品详情、团队、设置、消息、图片工具等,参考 `电商AI平台` 原版页面补齐。
- 如果 `v1/projects-new.html` 仍保留旧的多步配置,开发时按页面流程定稿收敛,只保留商品和项目名。
- 复制、归档、批量、审核流等非 V1 主路径能力,不因原型里出现就默认开发。
- `pipeline.html` 的视觉和结构可参考,但真实实现必须以 API 状态为准。
- 图片工具页可以复用模型能力,但不能阻塞主生产链路。
## 13. 后端实现清单
后端优先级:
1. Django 项目基础、环境配置、MySQL、Redis、Celery、TOS、日志。
2. 账号、团队、权限、商品、资产、项目基础模型和 API。
3. 额度账本与任务系统先落地,再接火山模型。
4. 火山模型适配层:文本、图片、视频统一接口,支持模型配置化。
5. 生产管线状态机Stage1 到 Stage5 的前置校验、推进和回滚。
6. Celery 异步任务AI 提交/轮询/转存/后处理/账本落账。
7. Stage5 FFmpeg 导出服务。
8. Django Admin 运营后台增强。
9. 审计日志、告警、失败补偿、清理任务。
企业级要求:
- AI 调用不能写死模型名和价格,必须走 `ModelConfig`
- 任务必须幂等,支持重试、取消和补偿。
- 额度扣费和任务成功必须在事务边界内保持一致。
- TOS 转存成功但数据库失败时,要有补偿或清理。
- 所有后台操作必须有审计日志。
- 所有外部 API key 只从环境变量读取。
## 14. 验收矩阵
| 编号 | 验收项 | 结果要求 |
| --- | --- | --- |
| A01 | 注册登录 | 新用户注册后自动创建团队并进入工作台。 |
| A02 | 商品创建 | 可上传商品图、填写信息、保存并在商品库搜索到。 |
| A03 | 项目创建 | 选择商品后创建项目,进入 Stage1。 |
| A04 | 脚本生成 | 调火山文本模型生成脚本,支持保存版本和采用。 |
| A05 | 基础资产 | 商品、人物、场景三类资产真实生成并采用。 |
| A06 | 故事板 | 可生成、采用、跳过,跳过不阻塞视频生成。 |
| A07 | 60s 视频 | 生成 4 个 15s 片段,支持单段失败重试。 |
| A08 | 拼接导出 | 4 段视频导出为 9:16 1080p MP4。 |
| A09 | 资产入库 | AI 图片、视频片段、成片自动进入资产库。 |
| A10 | 上传素材 | 用户上传视频能进入 Stage5 并参与导出。 |
| A11 | 额度预估 | 每次 AI 生成前展示预估消耗并校验余额。 |
| A12 | 失败不扣费 | 模型失败、超时、内容拦截时不最终扣费。 |
| A13 | 账单追溯 | 能按团队、成员、项目、任务查看费用流水。 |
| A14 | 后台排障 | 后台能查任务失败原因并安全重试。 |
| A15 | 团队隔离 | 不同团队无法访问彼此商品、项目、资产和账单。 |
## 15. 首轮开发建议
为了最快证明系统可行,建议第一轮只做一条“真实最小闭环”,但所有核心架构都按全量方案预留:
1. 登录/注册、团队、商品、资产、项目基础。
2. Stage1 真实脚本生成。
3. Stage2 真实生成一组商品/人物/场景基础资产。
4. Stage4 真实生成 1 个 15s 视频片段。
5. Stage5 用 1 个片段导出 MP4。
6. 额度账本贯穿上述每一步。
7. 运营后台能查看和重试任务。
第二轮扩展到完整 60s
1. Stage4 扩展为 4 个片段并行/排队生成。
2. Stage5 拼接 4 段并支持字幕、BGM、转场。
3. 补齐 Stage3 故事板。
4. 补齐项目列表、账单页、团队管理、资产库高级筛选。
## 16. 开发完成后的真实数据测试计划
本节参考 `/Users/maidong/Desktop/zyc/github/AI-Express/项目角色agent/test-agent.md` 的测试 Agent 规范,并结合 AirShelf 的真实 AI、额度、TOS、Celery 异步任务场景做项目化落地。
开发完成后的验收不能只看接口和页面静态效果,必须用测试环境真实资源、真实数据、真实浏览器点击,把主流程跑穿。这里的“真实”指:
- 使用测试 MySQL、Redis、TOS、火山模型不用 mock 代替核心外部依赖。
- 用 Playwright 或同类工具启动真实浏览器,按用户视角点击、输入、上传、等待和下载。
- 每次测试生成独立 `run_id`数据库记录、TOS object key、日志、账本、任务都能追踪到同一轮测试。
- 测试脚本不能直接调用业务 API 把项目状态改到下一步;只允许用 seed/cleanup 脚本准备和清理测试数据。
### 16.0 测试 Agent 执行规范
测试执行时按代码测试、浏览器实操、真实端到端、浏览器真实模拟、测试报告五层推进:
| 层级 | 名称 | 目标 | AirShelf 要求 |
| --- | --- | --- | --- |
| A | 代码层测试 | 验证后端、前端、任务代码基本正确。 | Django/DRF 单元测试、API 测试、Celery 任务测试、前端组件测试。 |
| B | 浏览器实操 | 打开真实页面点击、输入、hover、拖拽、移动端检查。 | 每次关键操作后检查 console error/warning 并截图。 |
| D | 真实端到端 | 验证真实环境和真实数据流通。 | MySQL、Redis、TOS、火山模型、FFmpeg、Celery 全部接真实测试资源。 |
| E | Playwright 真实模拟 | 验证用户在浏览器里实际看到和操作的结果。 | 禁止 mock API用真实账号、真实商品、真实上传、真实 AI 结果。 |
| C | 测试报告 | 输出可复查证据和 Bug 清单。 | 没有截图、trace、控制台日志、任务 ID 和账本 ID 的结论不算通过。 |
执行硬规则:
- 代码测试通过不等于测试完成;必须有 Playwright 浏览器截图验证。
- 每次点击、输入、提交、状态切换后都要检查控制台错误。
- 截图必须人工或视觉模型审查,不能只判断 DOM 存在。
- 初始判定默认是 `NEEDS WORK`,只有证据充分才给 `PASS`
- 报告里如果声称 0 问题,需要追加一轮复测。
- P0 Bug 发现后立即反馈开发修复,不等全量测试跑完。
- 同一个模块最多测试 3 轮;第 3 轮仍失败则进入升级处理。
本机执行建议:
- 优先复用全局 Playwright CLI不要重复项目级安装。
- 跑测试前先确认 `playwright --version` 和浏览器驱动是否存在。
- 缺浏览器驱动时只安装必需浏览器,优先 `chromium`
- 可回归测试写成 Playwright spec探索性排查可用 Playwright MCP 或交互式工具。
### 16.1 测试环境要求
| 类型 | 要求 |
| --- | --- |
| 数据库 | 使用 `account.md` 中的测试 MySQL 配置,单独测试库或测试 schema。 |
| Redis | 使用测试 Redis按正式 DB index 规划区分 cache、Celery broker、result backend、lock。 |
| TOS | 使用测试 bucket 或测试前缀,例如 `e2e/{run_id}/...`,测试结束可清理。 |
| 火山模型 | 使用真实 ARK/生图/生视频配置模型名、endpoint、价格来自 `ModelConfig`。 |
| Worker | Celery worker、Celery beat、FFmpeg、回调或轮询任务必须真实启动。 |
| 前端 | 使用接近正式构建的前端产物,不用开发模式里的假数据。 |
| 日志 | 后端日志、Celery 日志、浏览器 trace、截图、视频、导出文件都要保留到测试报告。 |
### 16.2 真实测试数据
每轮 E2E 测试用 seed 脚本准备下面数据:
| 数据 | 内容 |
| --- | --- |
| 测试团队 | `E2E Team {run_id}`,包含 owner、member、no_quota_user、disabled_user。 |
| 测试额度 | owner 有足够额度member 有月度额度no_quota_user 额度为 0。 |
| 测试商品 | 至少 3 个真实商品样例:护肤品、小家电、服饰;每个包含真实商品图、标题、品牌、类目、卖点。 |
| 测试素材 | 上传 1 个短视频、1 张商品图、1 张场景图、1 个 BGM 样例。 |
| 模型配置 | 文本、图片、视频、导出任务均有启用状态、单价、限流和能力类型。 |
| 管理员账号 | 可登录 Django Admin 或运营后台,验证任务、账本和补偿。 |
测试数据要求:
- 商品图片和上传视频必须是真文件,能上传到 TOS 并回显预览。
- 商品标题、卖点、脚本输入要覆盖中文、数字、标点和较长文本。
- 所有 seed 数据带 `run_id`,避免和人工测试数据混在一起。
### 16.3 浏览器 E2E 主流程
这些用例必须通过真实浏览器点击完成:
| 编号 | 场景 | 浏览器动作 | 验收结果 |
| --- | --- | --- | --- |
| E01 | 注册登录 | 打开注册页,填写账号,提交,进入工作台。 | 自动创建团队,导航和当前用户信息正确。 |
| E02 | 创建商品 | 进入商品库,点击新建,上传商品图,填写信息并保存。 | 商品出现在商品库,图片可预览,数据库和 TOS 有记录。 |
| E03 | 创建项目 | 从项目列表点击新建,选择商品,填写项目名,提交。 | 创建项目并进入 Stage1项目绑定正确商品。 |
| E04 | 生成脚本 | 在 Stage1 选择卖点,输入需求,点击 AI 生成,采用脚本版本。 | 真实调用文本模型,脚本版本落库,账本有预估和实扣。 |
| E05 | 生成基础资产 | 在 Stage2 依次生成商品、人物、场景资产,选择候选并采用。 | 真实生成图片TOS 有文件,资产库有记录,项目阶段可推进。 |
| E06 | 故事板 | Stage3 生成故事板并采用;另跑一条项目验证跳过故事板。 | 生成和跳过两条路径都能进入 Stage4。 |
| E07 | 生成 60s 视频 | Stage4 点击生成 4 个 15s 片段,等待完成,失败段单独重试。 | 4 段视频独立状态正确,成功片段入库,失败不扣最终费用。 |
| E08 | 拼接导出 | Stage5 自动放置片段,上传额外视频,添加字幕/BGM点击导出。 | 导出 9:16 1080p MP4能预览、下载、复制链接。 |
| E09 | 资产回查 | 进入资产库,按成片/视频/图片筛选,搜索本轮项目名。 | AI 产物、上传素材、最终成片都能找到并下载。 |
| E10 | 账单回查 | 进入消费页,按项目和成员筛选本轮流水。 | 每个 AI 任务和导出任务都有账本记录,余额变化正确。 |
| E11 | 团队权限 | 用 member、no_quota_user、disabled_user 分别登录访问同一流程。 | 权限、额度不足、账号禁用提示正确,不能越权。 |
| E12 | 运营后台 | 管理员打开后台,搜索本轮项目和任务,查看日志并触发安全重试。 | 能定位任务、资产、账本,重试不会重复扣费。 |
浏览器测试规则:
- 使用 Chromium 作为必跑浏览器;上线前增加 WebKit/Safari 兼容验证。
- 桌面视口必跑,移动视口至少覆盖项目列表、生产管线、资产库、账单页。
- 每次关键操作后检查 console error/warning。
- 每个关键阶段保存截图;失败时保存 Playwright trace、console log、network log。
- 截图中出现空白、错位、遮挡、placeholder 数据、异常报错,一律判失败。
- 长任务要通过 UI 轮询等待,不允许测试脚本直接改数据库状态。
- 下载的最终 MP4 要用 `ffprobe` 校验分辨率、时长、编码和文件大小。
### 16.4 异步任务与失败恢复测试
必须单独测试这些异常路径:
| 场景 | 验收要求 |
| --- | --- |
| 火山文本失败 | Stage1 显示失败原因,账本释放,支持重试。 |
| 图片生成超时 | Stage2 单项失败不污染已采用资产,支持重跑。 |
| 视频单段失败 | Stage4 只重跑失败段,其他段保持成功状态。 |
| TOS 转存失败 | 任务进入 `compensating``failed`,后台可补偿处理。 |
| Celery worker 重启 | 已提交任务能继续轮询或恢复,不能重复扣费。 |
| Redis 锁过期 | 同一任务重复提交时保持幂等。 |
| 导出失败 | Timeline 保存不丢失,用户可重新导出。 |
| 额度不足 | 前端阻止提交,后台没有创建实际 AI 任务。 |
### 16.5 账本一致性测试
每轮 E2E 完成后必须自动核对:
- 团队余额 = 初始余额 + 充值/调整 - 成功任务实扣。
- `CreditLedger` 每条流水都有 team、user、project、task、model 或 export job。
- 失败任务没有最终扣费;如果有冻结记录,必须有释放记录。
- 重试任务不能重复扣同一次失败费用。
- 后台人工补偿必须产生审计日志。
- 页面展示余额、数据库余额、账本汇总三者一致。
### 16.6 文件与成片质量测试
每个生成文件都要校验:
- TOS object 存在content type 正确,文件大小大于 0。
- 图片能打开,缩略图能显示。
- 视频片段能播放,时长接近 15s。
- 最终成片为 9:16、1080p、MP460s 成片时长允许合理浮动。
- 下载链接有过期时间,不暴露永久私有地址。
- 删除测试数据后TOS 测试前缀可被清理,不留下大量临时文件。
### 16.7 发布前测试门禁
进入正式部署前,必须满足:
- P0 浏览器 E2E 全部通过。
- P0 API 集成测试全部通过。
- 账本一致性核对为 0 差异。
- 没有 `compensating``running``reserved` 状态的遗留测试任务。
- TOS 没有孤儿文件,或孤儿文件已进入清理队列。
- 后台可查到本轮测试的项目、任务、资产、账本和导出记录。
- 测试报告包含run_id、测试账号、项目 ID、任务 ID、账本 ID、导出文件地址、截图和失败 trace。
### 16.8 测试报告与交接格式
每轮测试必须产出可追溯报告,建议目录:
- `test-reports/{run_id}/summary.md`
- `test-reports/{run_id}/screenshots/`
- `test-reports/{run_id}/traces/`
- `test-reports/{run_id}/videos/`
- `test-reports/{run_id}/logs/`
报告必须包含:
| 模块 | 内容 |
| --- | --- |
| 测试概览 | run_id、环境、前端版本、后端版本、测试账号、测试时间。 |
| 数据策略 | 全真实、真实+seed、mock 降级;必须说明原因。 |
| 服务状态 | 前端、后端、MySQL、Redis、Celery、TOS、火山模型、FFmpeg。 |
| 用例结果 | E01-E12 每一步的通过/失败、截图、任务 ID、账本 ID。 |
| 控制台错误 | 页面、操作、错误内容、严重度、截图。 |
| 网络/API 错误 | URL、状态码、响应摘要、复现步骤。 |
| Bug 清单 | 严重度、描述、定位、复现步骤、截图或 trace。 |
| 账本核对 | 初始余额、预估、冻结、实扣、释放、最终余额。 |
| 文件核对 | TOS key、文件大小、content type、视频时长、分辨率。 |
| 结论 | `QA PASS``QA FAIL``INCOMPLETE``ESCALATION`。 |
判定标准:
- `QA PASS`P0/P1 全部通过,账本 0 差异,浏览器截图和 trace 证据完整。
- `QA FAIL`:存在阻塞 Bug必须带复现步骤和证据返回开发。
- `INCOMPLETE`:环境未跑通、缺截图、缺真实数据、或跳过浏览器测试。
- `ESCALATION`:同一模块第 3 轮仍失败,或外部服务/架构问题阻塞继续测试。
交接给开发时,最后必须附结构化摘要:
```xml
<task-completion>
<status>completed | partial | failed</status>
<summary>一句话说明本轮测试结论</summary>
<deliverables>
- summary.md: done | partial | skipped
- screenshots: done | partial | skipped
- traces: done | partial | skipped
</deliverables>
<self-check-results>
- [x] 真实浏览器点击: PASS
- [x] 控制台错误检查: PASS
- [x] 真实数据验证: PASS
- [x] 账本一致性核对: PASS
</self-check-results>
<escalations>无,或列出需要上报的问题</escalations>
</task-completion>
```
## 17. 待确认项
这些项不阻塞开发,但进入正式计费和对外交付前需要确认:
- 火山各模型的正式 endpoint、并发限制、回调能力、计费口径。
- 60s 视频默认脚本分段策略:固定 4 段,还是按脚本语义自动切 4 段。
- 失败重试的免费次数和计费边界。
- BGM 来源:系统内置、用户上传、还是第三方库。
- 字幕样式默认模板数量。
- 成片是否加水印,测试环境和正式环境是否不同。
- 内容安全策略:由火山模型拦截、平台自审、还是两者结合。
- 导出文件保留周期和临时文件清理周期。

View File

@ -0,0 +1,10 @@
.venv/
**/__pycache__/
*.pyc
db.sqlite3
.env
.env.*
!.env.example
tests/
*.md
staticfiles/

24
core/backend/.env Normal file
View File

@ -0,0 +1,24 @@
DJANGO_SETTINGS_MODULE=airshelf.settings.development
DJANGO_SECRET_KEY=S2GYXa8YC21lmnFfVwC+6cyFNhCCSoclhpylOmSAm16vKflUgQi398VQQSM+Rbit
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=airshelf-web.airlabs.art,airshelf-web.test.airlabs.art,localhost,127.0.0.1,192.168.124.86
DJANGO_CSRF_TRUSTED_ORIGINS=https://airshelf-web.airlabs.art,https://airshelf-web.test.airlabs.art,https://airshelf.airlabs.art,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173,http://192.168.124.86:5173
CORS_ALLOWED_ORIGINS=https://airshelf-web.airlabs.art,https://airshelf-web.test.airlabs.art,https://airshelf.airlabs.art,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173,http://192.168.124.86:5173
DB_ENGINE=mysql
DB_NAME=airshelf_test
DB_USER=airshelf_app
DB_PASSWORD=d5020f4d41e0e4c52a371ecb913be3d1f1ab2b85
DB_HOST=14.103.27.192
DB_PORT=3306
DB_BIND_ADDRESS=192.168.124.137
REDIS_CACHE_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/0
CELERY_BROKER_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/1
CELERY_RESULT_BACKEND=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/2
REDIS_LOCK_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/3
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
TOS_BUCKET=airshelf
TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY
TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ==
VOLCANO_ARK_API_KEY=ark-24d5627e-28e4-4412-8679-46a6e9f26aab-6e951
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DEFAULT_TRIAL_CREDITS=1000.0000

26
core/backend/.env.example Normal file
View File

@ -0,0 +1,26 @@
DJANGO_SETTINGS_MODULE=airshelf.settings.development
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
DB_ENGINE=mysql
DB_NAME=airshelf_dev
DB_USER=airshelf
DB_PASSWORD=change-me
DB_HOST=127.0.0.1
DB_PORT=3306
DB_BIND_ADDRESS=
REDIS_CACHE_URL=redis://127.0.0.1:6379/0
CELERY_BROKER_URL=redis://127.0.0.1:6379/1
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
REDIS_LOCK_URL=redis://127.0.0.1:6379/3
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
TOS_BUCKET=airshelf
TOS_ACCESS_KEY_ID=change-me
TOS_SECRET_ACCESS_KEY=change-me
VOLCANO_ARK_API_KEY=change-me
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3

26
core/backend/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
# ---- AirShelf core backend: Django + DRF + gunicorn / celery ----
FROM docker.m.daocloud.io/python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
WORKDIR /app
# PyMySQL is pure-python (install_as_MySQLdb), boto3/gunicorn need no C deps,
# so the slim image is enough — no build-essential required.
COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . .
# Collected admin/static lands here; served by WhiteNoise (see settings/production.py)
RUN mkdir -p /app/staticfiles
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "airshelf.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]

44
core/backend/README.md Normal file
View File

@ -0,0 +1,44 @@
# AirShelf Backend
All backend code lives under `AirShelf/core/backend` by project decision.
## Local bootstrap
```bash
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/backend
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
Start workers in separate terminals:
```bash
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/backend
source .venv/bin/activate
celery -A airshelf worker -l info
```
`ffmpeg` must be available on `PATH` for Stage5 export jobs.
## Runtime layout
- Django project: `airshelf`
- Domain apps: `apps/*`
- Settings module: `airshelf.settings.development`
- Celery app: `airshelf.celery`
Secrets must be supplied by environment variables or `.env`; never commit values from `account.md`.
## Useful commands
```bash
python manage.py check
python manage.py makemigrations --check --dry-run
python manage.py migrate
python manage.py bootstrap_volcano_models
python manage.py test apps.accounts apps.projects apps.billing
```

View File

@ -0,0 +1,7 @@
try:
import pymysql
pymysql.install_as_MySQLdb()
except Exception:
pass

View File

@ -0,0 +1,9 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
application = get_asgi_application()

View File

@ -0,0 +1,11 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
app = Celery("airshelf")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,175 @@
from pathlib import Path
import os
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
load_dotenv(BASE_DIR / ".env")
def env(name: str, default: str | None = None) -> str | None:
return os.getenv(name, default)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_list(name: str, default: str = "") -> list[str]:
value = os.getenv(name, default)
return [item.strip() for item in value.split(",") if item.strip()]
SECRET_KEY = env("DJANGO_SECRET_KEY", "airshelf-dev-insecure-key")
DEBUG = env_bool("DJANGO_DEBUG", False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"apps.common",
"apps.accounts",
"apps.assets",
"apps.products",
"apps.projects",
"apps.ai",
"apps.billing",
"apps.ops",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "airshelf.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "airshelf.wsgi.application"
ASGI_APPLICATION = "airshelf.asgi.application"
if env("DB_ENGINE", "sqlite") == "mysql":
mysql_options = {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
}
if env("DB_BIND_ADDRESS"):
mysql_options["bind_address"] = env("DB_BIND_ADDRESS")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": env("DB_NAME", "airshelf"),
"USER": env("DB_USER", "airshelf"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST", "127.0.0.1"),
"PORT": env("DB_PORT", "3306"),
"OPTIONS": mysql_options,
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"DEFAULT_FILTER_BACKENDS": [
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"PAGE_SIZE": 20,
}
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS")
CORS_ALLOW_CREDENTIALS = True
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_CACHE_URL", "redis://127.0.0.1:6379/0"),
}
}
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2")
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TIMEZONE = TIME_ZONE
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
TOS = {
"endpoint": env("TOS_ENDPOINT"),
"bucket": env("TOS_BUCKET"),
"access_key_id": env("TOS_ACCESS_KEY_ID"),
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
}
VOLCANO = {
"ark_api_key": env("VOLCANO_ARK_API_KEY"),
"ark_base_url": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
}
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")

View File

@ -0,0 +1,5 @@
from .base import * # noqa: F403
DEBUG = True

View File

@ -0,0 +1,24 @@
from pathlib import Path
from .base import * # noqa: F403
from .base import BASE_DIR, MIDDLEWARE
DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Serve admin/static behind gunicorn with DEBUG=False (no nginx static mount needed).
# WhiteNoise sits right after SecurityMiddleware.
MIDDLEWARE = (
MIDDLEWARE[:1]
+ ["whitenoise.middleware.WhiteNoiseMiddleware"]
+ MIDDLEWARE[1:]
)
STATIC_ROOT = Path(BASE_DIR) / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

View File

@ -0,0 +1,13 @@
from .base import * # noqa: F403
DEBUG = False
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

View File

@ -0,0 +1,17 @@
from django.contrib import admin
from django.urls import include, path
from apps.common.views import health_check
urlpatterns = [
path("admin/", admin.site.urls),
path("api/health/", health_check, name="health-check"),
path("api/auth/", include("apps.accounts.urls")),
path("api/products/", include("apps.products.urls")),
path("api/assets/", include("apps.assets.urls")),
path("api/projects/", include("apps.projects.urls")),
path("api/billing/", include("apps.billing.urls")),
path("api/ai/", include("apps.ai.urls")),
path("api/ops/", include("apps.ops.urls")),
]

View File

@ -0,0 +1,9 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
application = get_wsgi_application()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,25 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import Team, TeamMember, User
@admin.register(User)
class AirShelfUserAdmin(UserAdmin):
list_display = ("username", "email", "status", "is_staff", "date_joined")
list_filter = ("status", "is_staff", "is_superuser")
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "status", "created_at")
search_fields = ("name", "owner__username", "owner__email")
list_filter = ("status",)
@admin.register(TeamMember)
class TeamMemberAdmin(admin.ModelAdmin):
list_display = ("team", "user", "role", "status", "monthly_credit_limit")
search_fields = ("team__name", "user__username", "user__email")
list_filter = ("role", "status")

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"

View File

@ -0,0 +1,245 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("phone", models.CharField(blank=True, max_length=32)),
("avatar_url", models.URLField(blank=True)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Team",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_teams",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TeamMember",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"role",
models.CharField(
choices=[
("owner", "Owner"),
("admin", "Admin"),
("member", "Member"),
("viewer", "Viewer"),
],
default="member",
max_length=24,
),
),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("invited", "Invited"),
("disabled", "Disabled"),
],
default="active",
max_length=24,
),
),
(
"monthly_credit_limit",
models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="team_memberships",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("team", "user")},
},
),
]

View File

@ -0,0 +1,94 @@
# Generated by Django 5.1.15 on 2026-06-08 09:48
import apps.accounts.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="LoginSession",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("user_agent", models.CharField(blank=True, max_length=400)),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("last_seen_at", models.DateTimeField(auto_now=True)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="login_sessions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-last_seen_at"],
},
),
migrations.CreateModel(
name="UserPreference",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notify",
models.JSONField(
blank=True, default=apps.accounts.models._default_notify
),
),
("two_factor_enabled", models.BooleanField(default=False)),
(
"creation_defaults",
models.JSONField(
blank=True, default=apps.accounts.models._default_creation
),
),
(
"display",
models.JSONField(
blank=True, default=apps.accounts.models._default_display
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="preference",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,97 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from apps.common.models import TimeStampedModel
class User(AbstractUser):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
phone = models.CharField(max_length=32, blank=True)
avatar_url = models.URLField(blank=True)
@property
def is_disabled(self) -> bool:
return self.status == self.Status.DISABLED
class Team(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name="owned_teams")
def __str__(self) -> str:
return self.name
def _default_notify() -> dict:
return {"n-export": True, "n-fail": True, "n-quota": True, "n-login": True}
def _default_creation() -> dict:
return {"template": "pain", "duration": "60", "subtitle": "big-variety", "bgm": "kapian", "transition": "fade"}
def _default_display() -> dict:
return {"appearance": "system", "language": "zh", "density": "standard"}
class UserPreference(TimeStampedModel):
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化(替代前端 localStorage)。"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="preference")
notify = models.JSONField(default=_default_notify, blank=True)
two_factor_enabled = models.BooleanField(default=False)
creation_defaults = models.JSONField(default=_default_creation, blank=True)
display = models.JSONField(default=_default_display, blank=True)
def __str__(self) -> str:
return f"prefs/{self.user}"
class LoginSession(TimeStampedModel):
"""登录会话记录:每次登录写一条(设备 UA / IP / 时间),供设置页「在用设备」展示与下线。"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="login_sessions")
user_agent = models.CharField(max_length=400, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
last_seen_at = models.DateTimeField(auto_now=True)
revoked_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-last_seen_at"]
class TeamMember(TimeStampedModel):
class Role(models.TextChoices):
OWNER = "owner", "Owner"
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
INVITED = "invited", "Invited"
DISABLED = "disabled", "Disabled"
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="members")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="team_memberships")
role = models.CharField(max_length=24, choices=Role.choices, default=Role.MEMBER)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
class Meta:
unique_together = [("team", "user")]
def __str__(self) -> str:
return f"{self.team} / {self.user} / {self.role}"

View File

@ -0,0 +1,94 @@
from rest_framework import serializers
from apps.billing.models import CreditAccount
from .models import LoginSession, Team, TeamMember, User, UserPreference
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name", "email", "phone", "avatar_url", "status"]
read_only_fields = ["id", "status"]
class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreference
fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"]
read_only_fields = ["updated_at"]
class LoginSessionSerializer(serializers.ModelSerializer):
is_current = serializers.SerializerMethodField()
class Meta:
model = LoginSession
fields = ["id", "user_agent", "ip_address", "last_seen_at", "created_at", "is_current"]
read_only_fields = fields
def get_is_current(self, obj) -> bool:
ctx = self.context or {}
return bool(obj.ip_address and obj.ip_address == ctx.get("current_ip") and obj.user_agent == ctx.get("current_ua"))
class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = ["id", "name", "status", "owner", "created_at", "updated_at"]
read_only_fields = ["id", "status", "owner", "created_at", "updated_at"]
class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
month_charged = serializers.SerializerMethodField()
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"]
read_only_fields = ["id", "team", "user", "status"]
def get_month_charged(self, obj):
charged_map = self.context.get("charged_map") or {}
return str(charged_map.get(obj.user_id, 0))
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
password = serializers.CharField(min_length=8, write_only=True)
email = serializers.EmailField(required=False, allow_blank=True)
team_name = serializers.CharField(max_length=128, required=False, allow_blank=True)
def validate_username(self, value):
if User.objects.filter(username=value).exists():
raise serializers.ValidationError("username already exists")
return value
def create(self, validated_data):
from decimal import Decimal
from django.conf import settings
from django.db import transaction
with transaction.atomic():
user = User.objects.create_user(
username=validated_data["username"],
password=validated_data["password"],
email=validated_data.get("email", ""),
)
team = Team.objects.create(
name=validated_data.get("team_name") or f"{user.username}'s Team",
owner=user,
)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(
team=team,
balance=Decimal(str(settings.DEFAULT_TRIAL_CREDITS)),
)
return {"user": user, "team": team}
class LoginSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(write_only=True)

View File

@ -0,0 +1,29 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.billing.models import CreditAccount
class AuthApiTests(TestCase):
def test_register_creates_user_team_member_credit_account_and_token(self):
client = APIClient()
response = client.post(
"/api/auth/register/",
{
"username": "new-owner",
"password": "strong-password",
"email": "owner@example.com",
"team_name": "Launch Team",
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertIn("token", response.data)
user = User.objects.get(username="new-owner")
team = Team.objects.get(name="Launch Team")
self.assertEqual(team.owner, user)
self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists())
self.assertTrue(CreditAccount.objects.filter(team=team).exists())

View File

@ -0,0 +1,34 @@
from django.urls import path
from .views import (
change_password,
login,
login_sessions,
logout,
me,
preferences,
register,
revoke_login_session,
revoke_other_sessions,
team_member_detail,
team_member_password,
team_members,
update_avatar,
)
urlpatterns = [
path("register/", register, name="auth-register"),
path("login/", login, name="auth-login"),
path("logout/", logout, name="auth-logout"),
path("me/", me, name="auth-me"),
path("me/password/", change_password, name="auth-change-password"),
path("me/avatar/", update_avatar, name="auth-avatar"),
path("me/preferences/", preferences, name="auth-preferences"),
path("me/sessions/", login_sessions, name="auth-sessions"),
path("me/sessions/revoke-others/", revoke_other_sessions, name="auth-sessions-revoke-others"),
path("me/sessions/<uuid:session_id>/revoke/", revoke_login_session, name="auth-session-revoke"),
path("team/members/", team_members, name="team-members"),
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),
]

View File

@ -0,0 +1,360 @@
import uuid
from pathlib import Path
from django.contrib.auth import authenticate
from django.db import transaction
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, parser_classes, permission_classes
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import LoginSession, TeamMember, User, UserPreference
from .serializers import (
LoginSerializer,
LoginSessionSerializer,
RegisterSerializer,
TeamMemberSerializer,
TeamSerializer,
UserPreferenceSerializer,
UserSerializer,
)
def auth_payload(user, team, token):
return {
"token": token.key,
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data,
}
def _client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR") or None
def record_login_session(request, user):
"""登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备,
已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免在用设备列表里同设备重复堆叠)"""
try:
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
ip_address = _client_ip(request)
existing = (
LoginSession.objects.filter(
user=user, user_agent=user_agent, ip_address=ip_address, revoked_at__isnull=True
)
.order_by("-last_seen_at")
.first()
)
if existing:
existing.save(update_fields=["last_seen_at"]) # auto_now 刷新最近活跃时间
else:
LoginSession.objects.create(user=user, user_agent=user_agent, ip_address=ip_address)
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
pass
@api_view(["POST"])
@permission_classes([])
def register(request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.save()
token, _ = Token.objects.get_or_create(user=data["user"])
record_login_session(request, data["user"])
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([])
def login(request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["username"],
password=serializer.validated_data["password"],
)
if user is None or user.is_disabled:
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(user)
token, _ = Token.objects.get_or_create(user=user)
record_login_session(request, user)
return Response(auth_payload(user, team, token))
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def logout(request):
Token.objects.filter(user=request.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def me(request):
user = request.user
if request.method == "PATCH":
if "name" in request.data:
user.first_name = str(request.data.get("name") or "").strip()
if "phone" in request.data:
user.phone = str(request.data.get("phone") or "").strip()[:32]
email = str(request.data.get("email") or "").strip()
if email:
user.email = email
user.save(update_fields=["first_name", "phone", "email"])
team = get_current_team(user)
return Response(
{
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data,
}
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def change_password(request):
user = request.user
old_password = str(request.data.get("old_password") or "")
new_password = str(request.data.get("new_password") or "").strip()
if not user.check_password(old_password):
return Response({"old_password": ["原密码不正确"]}, status=status.HTTP_400_BAD_REQUEST)
if len(new_password) < 8:
return Response({"new_password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.save(update_fields=["password"])
Token.objects.filter(user=user).delete()
token, _ = Token.objects.get_or_create(user=user)
return Response({"token": token.key})
@api_view(["POST", "DELETE"])
@parser_classes([MultiPartParser, FormParser])
@permission_classes([IsAuthenticated])
def update_avatar(request):
from apps.assets.storage import TosStorage
# DELETE = 恢复默认头像(清空 avatar_url,前端回退到首字母占位)
if request.method == "DELETE":
user = request.user
user.avatar_url = ""
user.save(update_fields=["avatar_url"])
return Response(UserSerializer(user).data)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST)
user = request.user
suffix = Path(upload.name).suffix.lower() or ".png"
object_key = f"users/{user.id}/avatar/{uuid.uuid4()}{suffix}"
storage = TosStorage()
storage.upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "image/png",
)
# 头像直接存可访问的预签名 URL(长有效期);后续如需永久化可改为读时签发
user.avatar_url = storage.presigned_get_url(object_key=object_key, expires_in=7 * 24 * 3600)
user.save(update_fields=["avatar_url"])
return Response(UserSerializer(user).data)
def normalize_member_role(role):
if role == "super":
return TeamMember.Role.OWNER
if role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN, TeamMember.Role.MEMBER, TeamMember.Role.VIEWER}:
return role
return TeamMember.Role.MEMBER
def can_manage_team(user, team):
member = TeamMember.objects.filter(team=team, user=user, status=TeamMember.Status.ACTIVE).first()
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
def _month_charged_by_user(team):
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
from django.db.models import Sum
from django.utils import timezone
from apps.billing.models import CreditLedger
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
rows = (
CreditLedger.objects.filter(
team=team,
ledger_type=CreditLedger.Type.CHARGE,
created_at__gte=month_start,
)
.values("user_id")
.annotate(total=Sum("amount"))
)
return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None}
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_members(request):
team = get_current_team(request.user)
if request.method == "GET":
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
charged_map = _month_charged_by_user(team)
return Response(
TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).data
)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
username = str(request.data.get("username") or "").strip()
password = str(request.data.get("password") or "").strip()
if not username:
return Response({"username": ["This field is required."]}, status=status.HTTP_400_BAD_REQUEST)
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"username": ["username already exists"]}, status=status.HTTP_400_BAD_REQUEST)
email = str(request.data.get("email") or "").strip() or f"{username}@airshelf.local"
role = normalize_member_role(request.data.get("role"))
if role == TeamMember.Role.OWNER:
role = TeamMember.Role.ADMIN
with transaction.atomic():
user = User.objects.create_user(username=username, password=password, email=email)
user.first_name = str(request.data.get("name") or "").strip()
user.save(update_fields=["first_name"])
member = TeamMember.objects.create(
team=team,
user=user,
role=role,
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,
)
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsAuthenticated])
def team_member_detail(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner cannot be changed"}, status=status.HTTP_400_BAD_REQUEST)
if request.method == "DELETE":
user = member.user
member.delete()
if not TeamMember.objects.filter(user=user).exists():
user.status = User.Status.DISABLED
user.save(update_fields=["status"])
Token.objects.filter(user=user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
role = request.data.get("role")
if role:
member.role = normalize_member_role(role)
if member.role == TeamMember.Role.OWNER:
member.role = TeamMember.Role.ADMIN
if "monthly_credit_limit" in request.data or "monthly" in request.data:
member.monthly_credit_limit = request.data.get("monthly_credit_limit", request.data.get("monthly")) or 0
name = str(request.data.get("name") or "").strip()
if name:
member.user.first_name = name
member.user.save(update_fields=["first_name"])
member.save(update_fields=["role", "monthly_credit_limit", "updated_at"])
return Response(TeamMemberSerializer(member).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def team_member_password(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner password cannot be reset here"}, status=status.HTTP_400_BAD_REQUEST)
password = str(request.data.get("password") or "").strip()
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
member.user.set_password(password)
member.user.save(update_fields=["password"])
Token.objects.filter(user=member.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["GET", "PUT", "PATCH"])
@permission_classes([IsAuthenticated])
def preferences(request):
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化。"""
pref, _ = UserPreference.objects.get_or_create(user=request.user)
if request.method in ("PUT", "PATCH"):
serializer = UserPreferenceSerializer(pref, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
pref.refresh_from_db()
return Response(UserPreferenceSerializer(pref).data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def login_sessions(request):
"""在用设备:返回未下线的登录会话(去重后最近 20 台)。
去重规则:同一台电脑(UA)+ 同一 IP 只算一台,取该设备最近一次会话展示(兼容历史已堆叠的重复行)"""
queryset = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).order_by("-last_seen_at")
seen: set = set()
unique = []
for session in queryset:
key = (session.user_agent, session.ip_address)
if key in seen:
continue
seen.add(key)
unique.append(session)
if len(unique) >= 20:
break
current_ip = _client_ip(request)
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
data = LoginSessionSerializer(unique, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
return Response(data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_login_session(request, session_id):
"""下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线,
否则去重展示的一台设备点下线,底层其它重复会话仍存活会再次冒出来"""
from django.utils import timezone
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
if not target:
return Response({"revoked": 0})
updated = LoginSession.objects.filter(
user=request.user,
user_agent=target.user_agent,
ip_address=target.ip_address,
revoked_at__isnull=True,
).update(revoked_at=timezone.now())
return Response({"revoked": updated})
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_other_sessions(request):
"""下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。"""
from django.utils import timezone
LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).update(revoked_at=timezone.now())
Token.objects.filter(user=request.user).delete()
token, _ = Token.objects.get_or_create(user=request.user)
record_login_session(request, request.user)
return Response({"token": token.key})

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,26 @@
from django.contrib import admin
from .models import AITask, ModelConfig, ModelProvider
@admin.register(ModelProvider)
class ModelProviderAdmin(admin.ModelAdmin):
list_display = ("name", "display_name", "status", "updated_at")
search_fields = ("name", "display_name")
list_filter = ("status",)
@admin.register(ModelConfig)
class ModelConfigAdmin(admin.ModelAdmin):
list_display = ("provider", "name", "capability", "unit_price", "status", "rate_limit_per_minute")
search_fields = ("provider__name", "name", "display_name")
list_filter = ("capability", "status")
@admin.register(AITask)
class AITaskAdmin(admin.ModelAdmin):
list_display = ("id", "team", "project", "task_type", "status", "model_config", "actual_cost", "updated_at")
search_fields = ("idempotency_key", "provider_task_id", "project__name", "team__name")
list_filter = ("task_type", "status", "model_config__capability")
readonly_fields = ("request_payload", "response_payload")

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.ai"

View File

@ -0,0 +1,73 @@
VOLCANO_PROVIDER = {
"name": "volcengine",
"display_name": "火山引擎(豆包)",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
}
VOLCANO_MODELS = [
{
"display_name": "Doubao-Seed-2.0-Pro",
"name": "doubao-seed-2-0-pro-260215",
"capability": "text",
"endpoint": "chat/completions",
"metadata": {"think": True, "source": "video-flow/data/vendor/volcengine.ts"},
},
{
"display_name": "Doubao-Seed-2.0-Lite",
"name": "doubao-seed-2-0-lite-260215",
"capability": "text",
"endpoint": "chat/completions",
"metadata": {"think": True, "source": "video-flow/data/vendor/volcengine.ts"},
},
{
"display_name": "Seedream-5.0",
"name": "doubao-seedream-5-0-260128",
"capability": "image",
"endpoint": "images/generations",
"metadata": {
"modes": ["text", "singleImage", "multiReference"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedream-4.5",
"name": "doubao-seedream-4-5-251128",
"capability": "image",
"endpoint": "images/generations",
"metadata": {
"modes": ["text", "singleImage", "multiReference"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedance-2.0",
"name": "doubao-seedance-2-0-260128",
"capability": "video",
"endpoint": "contents/generations/tasks",
"metadata": {
"audio": "optional",
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
"durations": list(range(4, 16)),
"resolutions": ["480p", "720p"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedance-1.5-Pro",
"name": "doubao-seedance-1-5-pro-251215",
"capability": "video",
"endpoint": "contents/generations/tasks",
"metadata": {
"audio": "optional",
"modes": ["text", "startFrameOptional"],
"durations": list(range(4, 13)),
"resolutions": ["480p", "720p", "1080p"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
]

View File

@ -0,0 +1,171 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ModelConfig",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
("display_name", models.CharField(max_length=128)),
(
"capability",
models.CharField(
choices=[
("text", "Text"),
("image", "Image"),
("video", "Video"),
("vision", "Vision"),
("export", "Export"),
],
max_length=32,
),
),
("endpoint", models.CharField(blank=True, max_length=255)),
(
"unit_price",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("rate_limit_per_minute", models.PositiveIntegerField(default=60)),
("metadata", models.JSONField(blank=True, default=dict)),
],
),
migrations.CreateModel(
name="ModelProvider",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=64, unique=True)),
("display_name", models.CharField(max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("base_url", models.URLField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AITask",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"task_type",
models.CharField(
choices=[
("script_generation", "Script Generation"),
("script_optimization", "Script Optimization"),
("product_image", "Product Image"),
("person_image", "Person Image"),
("scene_image", "Scene Image"),
("storyboard", "Storyboard"),
("video_segment", "Video Segment"),
("export", "Export"),
],
max_length=48,
),
),
(
"status",
models.CharField(
choices=[
("created", "Created"),
("reserved", "Reserved"),
("submitted", "Submitted"),
("polling", "Polling"),
("postprocessing", "Postprocessing"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("compensating", "Compensating"),
("cancelled", "Cancelled"),
],
default="created",
max_length=32,
),
),
("idempotency_key", models.CharField(max_length=128, unique=True)),
("provider_task_id", models.CharField(blank=True, max_length=255)),
("request_payload", models.JSONField(blank=True, default=dict)),
("response_payload", models.JSONField(blank=True, default=dict)),
(
"estimated_cost",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
(
"actual_cost",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
("error_code", models.CharField(blank=True, max_length=64)),
("error_message", models.TextField(blank=True)),
("submitted_at", models.DateTimeField(blank=True, null=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,78 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="aitask",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_tasks",
to="projects.project",
),
),
migrations.AddField(
model_name="aitask",
name="team",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
migrations.AddField(
model_name="aitask",
name="model_config",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="tasks",
to="ai.modelconfig",
),
),
migrations.AddField(
model_name="modelconfig",
name="provider",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="models",
to="ai.modelprovider",
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["team", "status"], name="ai_aitask_team_id_710ece_idx"
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["project", "task_type"], name="ai_aitask_project_f2850d_idx"
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["provider_task_id"], name="ai_aitask_provide_67beef_idx"
),
),
migrations.AlterUniqueTogether(
name="modelconfig",
unique_together={("provider", "name", "capability")},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,102 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class ModelProvider(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=64, unique=True)
display_name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
base_url = models.URLField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
def __str__(self) -> str:
return self.display_name
class ModelConfig(TimeStampedModel):
class Capability(models.TextChoices):
TEXT = "text", "Text"
IMAGE = "image", "Image"
VIDEO = "video", "Video"
VISION = "vision", "Vision"
EXPORT = "export", "Export"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
provider = models.ForeignKey(ModelProvider, on_delete=models.CASCADE, related_name="models")
name = models.CharField(max_length=128)
display_name = models.CharField(max_length=128)
capability = models.CharField(max_length=32, choices=Capability.choices)
endpoint = models.CharField(max_length=255, blank=True)
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
rate_limit_per_minute = models.PositiveIntegerField(default=60)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
unique_together = [("provider", "name", "capability")]
def __str__(self) -> str:
return f"{self.provider.name}:{self.name}:{self.capability}"
class AITask(TeamOwnedModel):
class Type(models.TextChoices):
SCRIPT_GENERATION = "script_generation", "Script Generation"
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
PRODUCT_IMAGE = "product_image", "Product Image"
PERSON_IMAGE = "person_image", "Person Image"
SCENE_IMAGE = "scene_image", "Scene Image"
STORYBOARD = "storyboard", "Storyboard"
VIDEO_SEGMENT = "video_segment", "Video Segment"
EXPORT = "export", "Export"
class Status(models.TextChoices):
CREATED = "created", "Created"
RESERVED = "reserved", "Reserved"
SUBMITTED = "submitted", "Submitted"
POLLING = "polling", "Polling"
POSTPROCESSING = "postprocessing", "Postprocessing"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
COMPENSATING = "compensating", "Compensating"
CANCELLED = "cancelled", "Cancelled"
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="ai_tasks",
)
task_type = models.CharField(max_length=48, choices=Type.choices)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
idempotency_key = models.CharField(max_length=128, unique=True)
provider_task_id = models.CharField(max_length=255, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
error_code = models.CharField(max_length=64, blank=True)
error_message = models.TextField(blank=True)
submitted_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["project", "task_type"]),
models.Index(fields=["provider_task_id"]),
]
def __str__(self) -> str:
return f"{self.task_type}:{self.status}:{self.id}"

View File

@ -0,0 +1,6 @@
from .base import AIProvider, AIProviderResult
from .volcano import VolcanoArkProvider
__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"]

View File

@ -0,0 +1,19 @@
from dataclasses import dataclass, field
from typing import Any, Protocol
@dataclass(frozen=True)
class AIProviderResult:
provider_task_id: str = ""
status: str = "succeeded"
payload: dict[str, Any] = field(default_factory=dict)
asset_urls: list[str] = field(default_factory=list)
class AIProvider(Protocol):
def submit(self, payload: dict[str, Any]) -> AIProviderResult:
...
def poll(self, provider_task_id: str) -> AIProviderResult:
...

View File

@ -0,0 +1,173 @@
from dataclasses import dataclass
import base64
from io import BytesIO
from typing import Any
import requests
from django.conf import settings
from .base import AIProviderResult
@dataclass
class VolcanoArkProvider:
api_key: str | None = None
base_url: str | None = None
def __post_init__(self) -> None:
self.api_key = self.api_key or settings.VOLCANO.get("ark_api_key")
self.base_url = self.base_url or settings.VOLCANO.get("ark_base_url")
def submit(self, payload: dict[str, Any]) -> AIProviderResult:
# The exact endpoint is resolved by ModelConfig; this adapter keeps IO centralized.
endpoint = payload.get("endpoint")
if not endpoint:
raise ValueError("Volcano request payload requires endpoint")
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}"},
json=payload.get("body", {}),
timeout=60,
)
response.raise_for_status()
data = response.json()
return AIProviderResult(
provider_task_id=str(data.get("id") or data.get("task_id") or ""),
status=str(data.get("status") or "submitted"),
payload=data,
)
def chat_completion(self, *, model: str, messages: list[dict[str, str]], endpoint: str = "chat/completions") -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json={"model": model, "messages": messages},
timeout=120,
)
response.raise_for_status()
return response.json()
@staticmethod
def extract_text(data: dict[str, Any]) -> str:
choices = data.get("choices") or []
if choices:
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
output = data.get("output")
if isinstance(output, str):
return output
raise ValueError("Volcano response does not contain text content")
def poll(self, provider_task_id: str) -> AIProviderResult:
if not provider_task_id:
raise ValueError("provider_task_id is required")
return AIProviderResult(provider_task_id=provider_task_id, status="polling", payload={})
def image_generation(
self,
*,
model: str,
prompt: str,
endpoint: str = "images/generations",
image: str | list[str] | None = None,
size: str = "2K",
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
"response_format": "url",
"watermark": False,
"size": size,
"sequential_image_generation": "disabled",
}
if image:
body["image"] = image
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=180,
)
response.raise_for_status()
return response.json()
def create_video_task(
self,
*,
model: str,
endpoint: str,
prompt: str,
ratio: str = "9:16",
duration: int = 15,
resolution: str = "720p",
reference_images: list[str] | None = None,
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for image_url in reference_images or []:
content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"})
body = {
"model": model,
"content": content,
"ratio": ratio,
"duration": duration,
"resolution": resolution,
"watermark": False,
"generate_audio": False,
}
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=120,
)
response.raise_for_status()
return response.json()
def poll_video_task(self, *, endpoint: str, provider_task_id: str) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
response = requests.get(
f"{self.base_url.rstrip('/')}/{endpoint.rstrip('/')}/{provider_task_id}",
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
response.raise_for_status()
return response.json()
@staticmethod
def extract_first_media_url(data: dict[str, Any]) -> str:
items = data.get("data") or []
for item in items:
if item.get("url"):
return item["url"]
if item.get("b64_json"):
return item["b64_json"]
content = data.get("content") or {}
if content.get("video_url"):
return content["video_url"]
raise ValueError("Volcano response does not contain media url")
@staticmethod
def media_to_bytes(media: str) -> tuple[BytesIO, str]:
if media.startswith("http://") or media.startswith("https://"):
response = requests.get(media, timeout=180)
response.raise_for_status()
return BytesIO(response.content), response.headers.get("content-type", "application/octet-stream")
if "," in media and media.startswith("data:"):
header, raw = media.split(",", 1)
content_type = header.split(";")[0].replace("data:", "") or "application/octet-stream"
return BytesIO(base64.b64decode(raw)), content_type
return BytesIO(base64.b64decode(media)), "image/png"

View File

@ -0,0 +1,44 @@
from rest_framework import serializers
from .models import AITask, ModelConfig, ModelProvider
class ModelProviderSerializer(serializers.ModelSerializer):
class Meta:
model = ModelProvider
fields = ["id", "name", "display_name", "status", "base_url", "metadata"]
read_only_fields = fields
class ModelConfigSerializer(serializers.ModelSerializer):
provider = ModelProviderSerializer(read_only=True)
class Meta:
model = ModelConfig
fields = ["id", "provider", "name", "display_name", "capability", "endpoint", "unit_price", "status", "metadata"]
read_only_fields = fields
class AITaskSerializer(serializers.ModelSerializer):
model_config = ModelConfigSerializer(read_only=True)
class Meta:
model = AITask
fields = [
"id",
"project",
"task_type",
"status",
"model_config",
"provider_task_id",
"estimated_cost",
"actual_cost",
"error_code",
"error_message",
"submitted_at",
"completed_at",
"created_at",
"updated_at",
]
read_only_fields = fields

View File

@ -0,0 +1,800 @@
import re
import subprocess
import tempfile
import uuid
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import VolcanoArkProvider
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
from apps.projects.models import (
BaseAssetGroup,
ExportJob,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
VideoSegment,
VideoSegmentVersion,
)
def get_default_model(capability: str) -> ModelConfig:
return (
ModelConfig.objects.select_related("provider")
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
.order_by("created_at")
.first()
)
def estimate_cost(model_config: ModelConfig) -> Decimal:
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[str] | None = None) -> list[dict[str, str]]:
product = project.product
selling_points = product.selling_points.all()
if selling_point_ids:
selling_points = selling_points.filter(id__in=selling_point_ids)
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
system = (
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
"拆成 4 个 15 秒段落。每段包含旁白、画面描述、商品露出方式和转场建议。"
)
user = f"""
商品标题{product.title}
品牌{product.brand or "未填写"}
类目{product.category or "未填写"}
目标人群{product.target_audience or "未填写"}
商品描述{product.description or "未填写"}
卖点
{selling_text or "未选择卖点,请根据商品信息自行提炼。"}
用户补充需求
{user_prompt or "生成一条结构完整、节奏清晰、适合投放的带货短视频脚本。"}
""".strip()
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
原实现按行 `[:4]`,ARK 返回整段散文时常变成第1镜有词2/3/4镜全空,
导致后续故事板帧 / 视频段拿到空提示词,前后内容断裂这里改为:
优先按空行/标号块切,块数够就把全部块均匀分桶;块不够再按句子切;仍不够则补齐
"""
def _bucketize(items: list[str], joiner: str) -> list[str]:
buckets: list[list[str]] = [[] for _ in range(count)]
per = len(items) / count
for index, item in enumerate(items):
buckets[min(count - 1, int(index / per))].append(item)
return [joiner.join(bucket).strip() for bucket in buckets]
text = (content or "").strip()
if not text:
return [""] * count
# 1) 优先按空行分段;只有一段时退回按行分
blocks = [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()]
if len(blocks) < 2:
blocks = [line.strip() for line in text.splitlines() if line.strip()]
if len(blocks) >= count:
return _bucketize(blocks, "\n")
# 2) 段落不足:按中英文句末标点切句,再均匀分桶
sentences = [s.strip() for s in re.split(r"(?<=[。!?!?.;\n])", text) if s.strip()]
if len(sentences) >= count:
return _bucketize(sentences, " ")
# 3) 仍不足:用已有块/句补齐到 count,绝不留空镜
base = blocks or sentences or [text]
filled = list(base)
while len(filled) < count:
filled.append(base[-1])
return filled[:count]
@transaction.atomic
def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, request_payload: dict) -> AITask:
cost = estimate_cost(model_config)
task = AITask.objects.create(
team=project.team,
created_by=user,
project=project,
task_type=task_type,
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}",
request_payload=request_payload,
estimated_cost=cost,
)
reserve_credit(team=project.team, user=user, task=task, amount=cost)
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
return task
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None) -> ScriptVersion:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
messages = build_script_prompt(project=project, user_prompt=user_prompt, selling_point_ids=selling_point_ids)
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages}
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.SCRIPT_GENERATION,
model_config=model_config,
request_payload=payload,
)
reservation = task.credit_reservation
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
content = provider.extract_text(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
script = ScriptVersion.objects.create(
project=project,
task=task,
title="AI 脚本",
content=content,
source="ai",
is_adopted=False,
)
for index, segment_text in enumerate(split_script_into_segments(content)):
ScriptSegment.objects.create(
script_version=script,
sort_order=index,
duration_seconds=15,
narration=segment_text,
visual_prompt=segment_text,
)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
return script
except Exception as exc:
with transaction.atomic():
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
"""用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。"""
if not video_bytes:
return None
try:
with tempfile.TemporaryDirectory(prefix="airshelf-poster-") as tmp:
tmp_dir = Path(tmp)
video_path = tmp_dir / "in.mp4"
poster_path = tmp_dir / "poster.jpg"
video_path.write_bytes(video_bytes)
proc = subprocess.run(
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
capture_output=True,
timeout=60,
)
if proc.returncode != 0 or not poster_path.exists():
return None
poster_bytes = poster_path.read_bytes()
if not poster_bytes:
return None
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}-poster.jpg"
return TosStorage().upload_fileobj(
fileobj=BytesIO(poster_bytes), object_key=object_key, content_type="image/jpeg"
)
except Exception: # noqa: BLE001 — poster 仅用于展示,失败不阻断
return None
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".png"
if "video" in content_type:
suffix = ".mp4"
elif "jpeg" in content_type:
suffix = ".jpg"
elif "webp" in content_type:
suffix = ".webp"
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=user,
name=name,
asset_type=asset_type,
source=Asset.Source.AI_GENERATED,
category=category,
origin_task=task,
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
if "video" in content_type:
try:
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
except Exception: # noqa: BLE001
video_bytes = b""
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
if poster:
AssetFile.objects.create(
asset=asset,
object_key=poster.object_key,
bucket=poster.bucket,
content_type=poster.content_type,
size_bytes=poster.size_bytes,
is_primary=False,
)
return asset
def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGroup:
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "kind": kind}
task = create_ai_task(
project=project,
user=user,
task_type={
BaseAssetGroup.Kind.PRODUCT: AITask.Type.PRODUCT_IMAGE,
BaseAssetGroup.Kind.PERSON: AITask.Type.PERSON_IMAGE,
BaseAssetGroup.Kind.SCENE: AITask.Type.SCENE_IMAGE,
}[kind],
model_config=model_config,
request_payload=payload,
)
reservation = task.credit_reservation
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
category = {
BaseAssetGroup.Kind.PRODUCT: Asset.Category.PRODUCT_IMAGE,
BaseAssetGroup.Kind.PERSON: Asset.Category.PERSON,
BaseAssetGroup.Kind.SCENE: Asset.Category.SCENE,
}[kind]
asset = _store_generated_media(
team=project.team,
user=user,
project=project,
task=task,
media=media,
name=f"{project.name}-{kind}",
category=category,
asset_type=Asset.Type.IMAGE,
)
group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt)
group.candidate_assets.add(asset)
group.adopted_asset = asset
group.save(update_fields=["adopted_asset", "updated_at"])
return group
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def _scene_context(project) -> str:
"""从商品 + 已采用基础资产提炼一句「风格锚点」,贯穿故事板 / 视频,保证各镜内容一致。"""
product = project.product
parts = [f"商品:{product.title}"]
if product.brand:
parts.append(f"品牌:{product.brand}")
if product.category:
parts.append(f"类目:{product.category}")
if getattr(product, "target_audience", ""):
parts.append(f"人群:{product.target_audience}")
adopted_kinds = set(
project.base_asset_groups.filter(adopted_asset__isnull=False).values_list("kind", flat=True)
)
if BaseAssetGroup.Kind.PERSON in adopted_kinds:
parts.append("真人出镜,保持人物一致")
if BaseAssetGroup.Kind.SCENE in adopted_kinds:
parts.append("统一场景与色调")
return " · ".join(parts)
def build_storyboard_frame_prompt(project, version, segment) -> str:
"""单帧故事板提示词:风格锚点 + 本镜画面(回退旁白)+ 版本统一指令。"""
visual = (segment.visual_prompt or segment.narration or "").strip()
lines = [
_scene_context(project),
f"{segment.sort_order + 1} 镜画面:{visual}" if visual else f"{segment.sort_order + 1}",
]
if version.prompt:
lines.append(version.prompt.strip())
lines.append("电商竖屏分镜图,构图清晰,可直接指导视频生成")
return "\n".join(line for line in lines if line)
def build_video_segment_prompt(project, video_segment, scene, user_prompt: str) -> str:
"""单段视频提示词:把本镜旁白 + 画面 + 风格锚点织进去,让每个视频片段跟住对应脚本/故事板。"""
lines = [_scene_context(project)]
if scene is not None:
if scene.narration:
lines.append(f"旁白:{scene.narration.strip()}")
visual = (scene.visual_prompt or scene.narration or "").strip()
if visual:
lines.append(f"画面:{visual}")
if user_prompt:
lines.append(user_prompt.strip())
lines.append(
f"{video_segment.sort_order + 1} 段 · {video_segment.target_duration_seconds}s · "
"9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感"
)
return "\n".join(line for line in lines if line)
def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
"""异步故事板·提交:快速创建(或复用)一个未采用的版本,不在此处生图。逐帧生成交给 generate_storyboard_frame(轮询)。"""
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if adopted_script is None:
raise ValueError("script must be adopted before generating storyboard")
if get_default_model(ModelConfig.Capability.IMAGE) is None:
raise ValueError("no active image model configured")
# 复用尚未完成(未采用)的版本,避免重复提交产生多版本;否则新建
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
if version is None:
version = StoryboardVersion.objects.create(project=project, prompt=prompt)
elif prompt and version.prompt != prompt:
version.prompt = prompt
version.save(update_fields=["prompt", "updated_at"])
return version
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。"""
import threading # noqa: F401 — 仅标注此函数运行在独立线程
from django.db import connections
from apps.accounts.models import User
try:
task = AITask.objects.select_related("model_config__provider").get(id=task_id)
version = StoryboardVersion.objects.select_related("project__team").get(id=version_id)
segment = ScriptSegment.objects.get(id=segment_id)
user = User.objects.get(id=user_id)
project = version.project
model_config = task.model_config
reservation = task.credit_reservation
task.status = AITask.Status.SUBMITTED
task.save(update_fields=["status", "updated_at"])
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=frame_prompt,
)
media = provider.extract_first_media_url(response)
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
asset = _store_generated_media(
team=project.team,
user=user,
project=project,
task=task,
media=media,
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
category=Asset.Category.SCENE,
asset_type=Asset.Type.IMAGE,
)
StoryboardFrame.objects.create(
storyboard=version,
script_segment=segment,
asset=asset,
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
finally:
connections.close_all() # 释放该线程的 DB 连接
def generate_storyboard_frame(*, project, user) -> dict:
"""异步故事板·轮询(秒回):读取进度;若无帧在生成则后台起线程生成下一帧。永不阻塞在 ARK 调用上。
返回 {status: generating|succeeded|failed, done, total, version_id}全部完成采用版本"""
import threading
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if version is None or adopted_script is None:
latest = project.storyboard_versions.order_by("-created_at").first()
n = latest.frames.count() if latest else 0
return {"status": "succeeded", "done": n, "total": n, "version_id": str(latest.id) if latest else ""}
segments = list(adopted_script.segments.all().order_by("sort_order"))
total = len(segments)
done_segment_ids = set(version.frames.values_list("script_segment_id", flat=True))
done = len(done_segment_ids)
if done >= total:
_finalize_storyboard(project, version)
return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)}
# 该版本内是否已有帧在后台生成中(RESERVED/SUBMITTED 的故事板任务即为「占位锁」)。
# 仅算「近 3 分钟内」的任务:若进程/线程意外中断留下僵尸任务,超时后不再视为在生成,允许重新发起。
stale_cutoff = timezone.now() - timedelta(minutes=3)
inflight = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
request_payload__storyboard_version=str(version.id),
created_at__gte=stale_cutoff,
).exists()
if inflight:
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
pending = [s for s in segments if s.id not in done_segment_ids]
segment = pending[0]
# 单帧失败次数上限,避免持续失败时无限重试
failed_for_segment = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id),
).count()
if failed_for_segment >= 2:
last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first()
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
"error": last.error_message if last else "storyboard frame failed"}
model_config = get_default_model(ModelConfig.Capability.IMAGE)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": build_storyboard_frame_prompt(project, version, segment),
"storyboard_version": str(version.id),
"storyboard_segment": str(segment.id),
},
)
threading.Thread(
target=_storyboard_frame_worker,
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
daemon=True,
).start()
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
def _finalize_storyboard(project, version) -> None:
"""全部帧就绪:采用该版本(反采用其余版本)。项目阶段推进由视图负责(与原同步实现一致)。"""
project.storyboard_versions.exclude(id=version.id).update(is_adopted=False)
if not version.is_adopted:
version.is_adopted = True
version.save(update_fields=["is_adopted", "updated_at"])
def _asset_preview_url(asset) -> str:
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。"""
if asset is None:
return ""
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None:
return ""
if primary.preview_url:
return primary.preview_url
try:
return TosStorage().presigned_get_url(object_key=primary.object_key)
except Exception:
return ""
def _video_reference_images(project, video_segment) -> list[str]:
"""为本视频段挑一张视觉参考图:优先本镜故事板帧,兜底已采用商品基础资产。"""
version = (
project.storyboard_versions.filter(is_adopted=True).order_by("-created_at").first()
or project.storyboard_versions.order_by("-created_at").first()
)
if version is not None:
frame = (
version.frames.filter(sort_order=video_segment.sort_order).first()
or version.frames.order_by("sort_order").first()
)
if frame is not None:
url = _asset_preview_url(frame.asset)
if url:
return [url]
product_group = (
project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False)
.order_by("-created_at")
.first()
)
if product_group is not None:
url = _asset_preview_url(product_group.adopted_asset)
if url:
return [url]
return []
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
model_config = get_default_model(ModelConfig.Capability.VIDEO)
if model_config is None:
raise ValueError("no active video model configured")
project = video_segment.project
# 衔接:按 sort_order 把视频段绑到对应脚本镜,并织出跟住该镜的提示词。
scene = None
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if adopted_script is not None:
scene = adopted_script.segments.filter(sort_order=video_segment.sort_order).first()
if scene is not None and video_segment.script_segment_id != scene.id:
video_segment.script_segment = scene
video_segment.save(update_fields=["script_segment", "updated_at"])
final_prompt = build_video_segment_prompt(project, video_segment, scene, prompt)
# 参考图:优先用本镜故事板帧,其次商品/人物基础资产,给视频做视觉锚点(衔接故事板→视频)。
reference_images = _video_reference_images(project, video_segment)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": final_prompt,
"duration": video_segment.target_duration_seconds,
"ratio": "9:16",
"video_segment_id": str(video_segment.id),
"reference_images": reference_images,
},
)
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
try:
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=final_prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
reference_images=reference_images or None,
)
except Exception:
# 降级:带参考图被拒时退回纯文生视频(文本里已含本镜旁白/画面,衔接不丢)
if not reference_images:
raise
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=final_prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
reference_images=None,
)
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
task.response_payload = response
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"])
video_segment.status = VideoSegment.Status.RUNNING
video_segment.save(update_fields=["status", "updated_at"])
return None
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=task.credit_reservation, reason=str(exc))
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = str(exc)
video_segment.save(update_fields=["status", "error_message", "updated_at"])
raise
def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVersion | None:
# 幂等:已完成的段直接回采用版;已失败的段不再 poll。避免对已成功 task 再 poll → 二次建版 / 二次扣费。
if video_segment.status == VideoSegment.Status.SUCCEEDED:
return video_segment.adopted_version or video_segment.versions.order_by("-created_at").first()
if video_segment.status == VideoSegment.Status.FAILED:
return None
task = video_segment.versions.order_by("-created_at").first()
ai_task = None
if task:
ai_task = task.task
if ai_task is None:
ai_task = video_segment.project.ai_tasks.filter(
task_type=AITask.Type.VIDEO_SEGMENT,
request_payload__video_segment_id=str(video_segment.id),
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
).order_by("-created_at").first()
if ai_task is None:
raise ValueError("no active video generation task")
# task 已终态(可能被并发的 worker / 另一次 poll 处理过):直接回已有版,不再调 ARK。
if ai_task.status == AITask.Status.SUCCEEDED:
return video_segment.versions.filter(task=ai_task).order_by("-created_at").first()
if ai_task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
return None
provider = VolcanoArkProvider(base_url=ai_task.model_config.provider.base_url or None)
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")
if remote_status in {"queued", "running", "processing"}:
ai_task.status = AITask.Status.POLLING
ai_task.response_payload = response
ai_task.save(update_fields=["status", "response_payload", "updated_at"])
return None
if remote_status in {"failed", "expired", "cancelled"}:
ai_task.status = AITask.Status.FAILED
ai_task.response_payload = response
ai_task.error_message = response.get("error", {}).get("message", "video generation failed")
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"])
release_credit(reservation=ai_task.credit_reservation, reason=ai_task.error_message)
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = ai_task.error_message
video_segment.save(update_fields=["status", "error_message", "updated_at"])
return None
media = provider.extract_first_media_url(response)
asset = _store_generated_media(
team=video_segment.project.team,
user=user,
project=video_segment.project,
task=ai_task,
media=media,
name=f"{video_segment.project.name}-segment-{video_segment.sort_order + 1}",
category=Asset.Category.VIDEO_CLIP,
asset_type=Asset.Type.VIDEO,
)
ai_task.status = AITask.Status.SUCCEEDED
ai_task.response_payload = response
ai_task.actual_cost = ai_task.estimated_cost
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=ai_task.credit_reservation, actual_amount=ai_task.actual_cost)
version = VideoSegmentVersion.objects.create(
video_segment=video_segment,
task=ai_task,
asset=asset,
prompt=ai_task.request_payload.get("prompt", ""),
is_adopted=True,
)
video_segment.adopted_version = version
video_segment.status = VideoSegment.Status.SUCCEEDED
video_segment.error_message = ""
video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return version
def create_export_job(*, timeline, user) -> ExportJob:
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
_STANDALONE_CATEGORY = {
"model": Asset.Category.PERSON,
"cover": Asset.Category.PRODUCT_IMAGE,
"image": Asset.Category.PRODUCT_IMAGE,
}
_STANDALONE_TASK_TYPE = {
"model": AITask.Type.PERSON_IMAGE,
"cover": AITask.Type.PRODUCT_IMAGE,
"image": AITask.Type.PRODUCT_IMAGE,
}
def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", count: int = 1) -> list[Asset]:
"""不绑定项目的独立生图(图片创作 / 模特上身图 / 平台套图)。复用项目内生图链路,AITask.project=None。"""
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
count = max(1, min(int(count or 1), 4))
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
assets: list[Asset] = []
for index in range(count):
cost = estimate_cost(model_config)
task = AITask.objects.create(
team=team,
created_by=user,
project=None,
task_type=task_type,
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode},
estimated_cost=cost,
)
reserve_credit(team=team, user=user, task=task, amount=cost)
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
reservation = task.credit_reservation
try:
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png")
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
asset = Asset.objects.create(
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
)
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
assets.append(asset)
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
return assets

View File

@ -0,0 +1,12 @@
from airshelf.celery import app
@app.task(bind=True, max_retries=3)
def submit_ai_task(self, task_id: str) -> str:
return task_id
@app.task(bind=True, max_retries=5)
def poll_ai_task(self, task_id: str) -> str:
return task_id

View File

@ -0,0 +1,12 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import AITaskViewSet, GenerateImageView, ModelConfigViewSet
router = DefaultRouter()
router.register("tasks", AITaskViewSet, basename="ai-task")
router.register("models", ModelConfigViewSet, basename="model-config")
urlpatterns = [
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
] + router.urls

View File

@ -0,0 +1,48 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ReadOnlyModelViewSet
from apps.assets.serializers import AssetSerializer
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import AITask, ModelConfig
from .serializers import AITaskSerializer, ModelConfigSerializer
from .services import generate_standalone_image
class GenerateImageView(APIView):
"""POST /api/ai/generate-image/ — 独立生图(不绑项目)· 图片创作/模特图/平台套图共用。"""
def post(self, request):
prompt = str(request.data.get("prompt") or "").strip()
if not prompt:
return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
mode = str(request.data.get("mode") or "image")
try:
count = int(request.data.get("count") or 1)
except (TypeError, ValueError):
count = 1
team = get_current_team(request.user)
try:
assets = generate_standalone_image(team=team, user=request.user, prompt=prompt, mode=mode, count=count)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 生成失败已回滚额度,返回明确错误给前端
return Response({"detail": f"生成失败: {exc}"}, status=status.HTTP_502_BAD_GATEWAY)
return Response({"assets": AssetSerializer(assets, many=True).data}, status=status.HTTP_201_CREATED)
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").all()
serializer_class = AITaskSerializer
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
ordering_fields = ["created_at", "updated_at", "completed_at"]
class ModelConfigViewSet(ReadOnlyModelViewSet):
queryset = ModelConfig.objects.select_related("provider").filter(status=ModelConfig.Status.ACTIVE)
serializer_class = ModelConfigSerializer
search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,22 @@
from django.contrib import admin
from .models import Asset, AssetFile, AssetTag, AssetTagging, AssetUsage
class AssetFileInline(admin.TabularInline):
model = AssetFile
extra = 0
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
list_display = ("name", "team", "asset_type", "source", "category", "is_deleted", "created_at")
search_fields = ("name", "team__name")
list_filter = ("asset_type", "source", "category", "is_deleted")
inlines = [AssetFileInline]
admin.site.register(AssetTag)
admin.site.register(AssetTagging)
admin.site.register(AssetUsage)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AssetsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.assets"

View File

@ -0,0 +1,232 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Asset",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
(
"asset_type",
models.CharField(
choices=[
("image", "Image"),
("video", "Video"),
("audio", "Audio"),
("subtitle", "Subtitle"),
("document", "Document"),
],
max_length=24,
),
),
(
"source",
models.CharField(
choices=[
("upload", "Upload"),
("ai_generated", "AI Generated"),
("exported", "Exported"),
("system", "System"),
],
max_length=32,
),
),
(
"category",
models.CharField(
choices=[
("person", "Person"),
("scene", "Scene"),
("product_image", "Product Image"),
("video_clip", "Video Clip"),
("final_video", "Final Video"),
("upload", "Upload"),
("uncategorized", "Uncategorized"),
],
default="uncategorized",
max_length=32,
),
),
("description", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_deleted", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"origin_task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="generated_assets",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AssetFile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_key", models.CharField(max_length=512)),
("bucket", models.CharField(max_length=128)),
("content_type", models.CharField(blank=True, max_length=128)),
("size_bytes", models.BigIntegerField(default=0)),
("checksum", models.CharField(blank=True, max_length=128)),
("width", models.PositiveIntegerField(blank=True, null=True)),
("height", models.PositiveIntegerField(blank=True, null=True)),
("duration_ms", models.PositiveIntegerField(blank=True, null=True)),
("preview_url", models.URLField(blank=True)),
("is_primary", models.BooleanField(default=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="assets.asset",
),
),
],
),
migrations.CreateModel(
name="AssetTag",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=64)),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_tags",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="AssetTagging",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.asset",
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.assettag",
),
),
],
),
migrations.CreateModel(
name="AssetUsage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("usage_type", models.CharField(max_length=64)),
("context", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="usages",
to="assets.asset",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("assets", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="assetusage",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_usages",
to="projects.project",
),
),
migrations.AddIndex(
model_name="assetfile",
index=models.Index(
fields=["bucket", "object_key"], name="assets_asse_bucket_94a505_idx"
),
),
migrations.AlterUniqueTogether(
name="assettag",
unique_together={("team", "name")},
),
migrations.AlterUniqueTogether(
name="assettagging",
unique_together={("asset", "tag")},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,100 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Asset(TeamOwnedModel):
class Type(models.TextChoices):
IMAGE = "image", "Image"
VIDEO = "video", "Video"
AUDIO = "audio", "Audio"
SUBTITLE = "subtitle", "Subtitle"
DOCUMENT = "document", "Document"
class Source(models.TextChoices):
UPLOAD = "upload", "Upload"
AI_GENERATED = "ai_generated", "AI Generated"
EXPORTED = "exported", "Exported"
SYSTEM = "system", "System"
class Category(models.TextChoices):
PERSON = "person", "Person"
SCENE = "scene", "Scene"
PRODUCT_IMAGE = "product_image", "Product Image"
VIDEO_CLIP = "video_clip", "Video Clip"
FINAL_VIDEO = "final_video", "Final Video"
UPLOAD = "upload", "Upload"
UNCATEGORIZED = "uncategorized", "Uncategorized"
name = models.CharField(max_length=255)
asset_type = models.CharField(max_length=24, choices=Type.choices)
source = models.CharField(max_length=32, choices=Source.choices)
category = models.CharField(max_length=32, choices=Category.choices, default=Category.UNCATEGORIZED)
description = models.TextField(blank=True)
origin_task = models.ForeignKey(
"ai.AITask",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="generated_assets",
)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
def __str__(self) -> str:
return self.name
class AssetFile(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="files")
object_key = models.CharField(max_length=512)
bucket = models.CharField(max_length=128)
content_type = models.CharField(max_length=128, blank=True)
size_bytes = models.BigIntegerField(default=0)
checksum = models.CharField(max_length=128, blank=True)
width = models.PositiveIntegerField(null=True, blank=True)
height = models.PositiveIntegerField(null=True, blank=True)
duration_ms = models.PositiveIntegerField(null=True, blank=True)
preview_url = models.URLField(blank=True)
is_primary = models.BooleanField(default=True)
class Meta:
indexes = [
models.Index(fields=["bucket", "object_key"]),
]
def __str__(self) -> str:
return self.object_key
class AssetTag(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="asset_tags")
name = models.CharField(max_length=64)
class Meta:
unique_together = [("team", "name")]
def __str__(self) -> str:
return self.name
class AssetTagging(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="taggings")
tag = models.ForeignKey(AssetTag, on_delete=models.CASCADE, related_name="taggings")
class Meta:
unique_together = [("asset", "tag")]
class AssetUsage(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="usages")
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="asset_usages",
)
usage_type = models.CharField(max_length=64)
context = models.JSONField(default=dict, blank=True)

View File

@ -0,0 +1,87 @@
from django.conf import settings
from rest_framework import serializers
from .models import Asset, AssetFile
from .storage import TosStorage
_tos_storage = None
def _tos():
global _tos_storage
if _tos_storage is None:
_tos_storage = TosStorage()
return _tos_storage
class AssetFileSerializer(serializers.ModelSerializer):
preview_url = serializers.SerializerMethodField()
class Meta:
model = AssetFile
fields = [
"id",
"object_key",
"bucket",
"content_type",
"size_bytes",
"width",
"height",
"duration_ms",
"preview_url",
"is_primary",
]
read_only_fields = [
"id",
"object_key",
"bucket",
"content_type",
"size_bytes",
"width",
"height",
"duration_ms",
"is_primary",
]
def get_preview_url(self, obj):
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 实时签发 TOS 预签名 GET URL
if obj.preview_url:
return obj.preview_url
if not obj.object_key or not settings.TOS.get("endpoint"):
return ""
try:
return _tos().presigned_get_url(object_key=obj.object_key)
except Exception:
return ""
class AssetSerializer(serializers.ModelSerializer):
files = AssetFileSerializer(many=True, read_only=True)
class Meta:
model = Asset
fields = [
"id",
"name",
"asset_type",
"source",
"category",
"description",
"metadata",
"is_deleted",
"origin_task",
"files",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class AssetUploadSerializer(serializers.Serializer):
file = serializers.FileField()
name = serializers.CharField(max_length=255, required=False, allow_blank=True)
asset_type = serializers.ChoiceField(choices=Asset.Type.choices)
category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD)
description = serializers.CharField(required=False, allow_blank=True)

View File

@ -0,0 +1,52 @@
from dataclasses import dataclass
from typing import BinaryIO
import boto3
from botocore.config import Config
from django.conf import settings
@dataclass(frozen=True)
class StoredObject:
bucket: str
object_key: str
content_type: str
size_bytes: int
class TosStorage:
def __init__(self) -> None:
tos = settings.TOS
self.bucket = tos["bucket"]
self.client = boto3.client(
"s3",
endpoint_url=tos["endpoint"],
aws_access_key_id=tos["access_key_id"],
aws_secret_access_key=tos["secret_access_key"],
region_name="cn-shanghai",
config=Config(s3={"addressing_style": "virtual"}),
)
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
fileobj.seek(0, 2)
size = fileobj.tell()
fileobj.seek(0)
self.client.upload_fileobj(
fileobj,
self.bucket,
object_key,
ExtraArgs={"ContentType": content_type},
)
return StoredObject(
bucket=self.bucket,
object_key=object_key,
content_type=content_type,
size_bytes=size,
)
def presigned_get_url(self, *, object_key: str, expires_in: int = 3600) -> str:
return self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": object_key},
ExpiresIn=expires_in,
)

View File

@ -0,0 +1,11 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import AssetUploadView, AssetViewSet
router = DefaultRouter()
router.register("", AssetViewSet, basename="asset")
urlpatterns = [
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
] + router.urls

View File

@ -0,0 +1,61 @@
from pathlib import Path
import uuid
from django.db import transaction
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import Asset, AssetFile
from .serializers import AssetSerializer, AssetUploadSerializer
from .storage import TosStorage
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Asset.objects.prefetch_related("files").all()
serializer_class = AssetSerializer
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser]
@transaction.atomic
def post(self, request):
serializer = AssetUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
team = get_current_team(request.user)
upload = serializer.validated_data["file"]
suffix = Path(upload.name).suffix.lower()
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=serializer.validated_data.get("name") or upload.name,
asset_type=serializer.validated_data["asset_type"],
source=Asset.Source.UPLOAD,
category=serializer.validated_data["category"],
description=serializer.validated_data.get("description", ""),
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,21 @@
from django.contrib import admin
from .models import CreditAccount, CreditLedger, CreditReservation, QuotaPolicy
@admin.register(CreditAccount)
class CreditAccountAdmin(admin.ModelAdmin):
list_display = ("team", "balance", "reserved_balance", "currency", "updated_at")
search_fields = ("team__name",)
@admin.register(CreditLedger)
class CreditLedgerAdmin(admin.ModelAdmin):
list_display = ("team", "user", "project", "task", "ledger_type", "amount", "balance_after", "created_at")
search_fields = ("team__name", "user__username", "project__name", "task__idempotency_key")
list_filter = ("ledger_type",)
admin.site.register(CreditReservation)
admin.site.register(QuotaPolicy)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.billing"

View File

@ -0,0 +1,277 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0002_initial"),
("projects", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="CreditAccount",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"balance",
models.DecimalField(decimal_places=4, default=0, max_digits=14),
),
(
"reserved_balance",
models.DecimalField(decimal_places=4, default=0, max_digits=14),
),
("currency", models.CharField(default="CNY", max_length=16)),
(
"team",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_account",
to="accounts.team",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="CreditReservation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("amount", models.DecimalField(decimal_places=4, max_digits=14)),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("released", "Released"),
("charged", "Charged"),
("cancelled", "Cancelled"),
],
default="active",
max_length=32,
),
),
("expires_at", models.DateTimeField(blank=True, null=True)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_reservations",
to="projects.project",
),
),
(
"task",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_reservation",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_reservations",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_reservations",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="QuotaPolicy",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"monthly_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
(
"project_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
(
"per_task_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
("is_active", models.BooleanField(default=True)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to="projects.project",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="CreditLedger",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"ledger_type",
models.CharField(
choices=[
("recharge", "Recharge"),
("reserve", "Reserve"),
("release", "Release"),
("charge", "Charge"),
("adjustment", "Adjustment"),
("refund", "Refund"),
],
max_length=32,
),
),
("amount", models.DecimalField(decimal_places=4, max_digits=14)),
("balance_after", models.DecimalField(decimal_places=4, max_digits=14)),
("reason", models.CharField(blank=True, max_length=255)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_ledgers",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["team", "ledger_type"],
name="billing_cre_team_id_e0f18f_idx",
),
models.Index(
fields=["project", "task"],
name="billing_cre_project_a79834_idx",
),
],
},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,84 @@
from django.db import models
from apps.common.models import TimeStampedModel
class CreditAccount(TimeStampedModel):
team = models.OneToOneField("accounts.Team", on_delete=models.CASCADE, related_name="credit_account")
balance = models.DecimalField(max_digits=14, decimal_places=4, default=0)
reserved_balance = models.DecimalField(max_digits=14, decimal_places=4, default=0)
currency = models.CharField(max_length=16, default="CNY")
def __str__(self) -> str:
return f"{self.team} / {self.balance}"
class CreditLedger(TimeStampedModel):
class Type(models.TextChoices):
RECHARGE = "recharge", "Recharge"
RESERVE = "reserve", "Reserve"
RELEASE = "release", "Release"
CHARGE = "charge", "Charge"
ADJUSTMENT = "adjustment", "Adjustment"
REFUND = "refund", "Refund"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="credit_ledgers")
user = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_ledgers")
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="credit_ledgers",
)
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_ledgers")
ledger_type = models.CharField(max_length=32, choices=Type.choices)
amount = models.DecimalField(max_digits=14, decimal_places=4)
balance_after = models.DecimalField(max_digits=14, decimal_places=4)
reason = models.CharField(max_length=255, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "ledger_type"]),
models.Index(fields=["project", "task"]),
]
class CreditReservation(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
RELEASED = "released", "Released"
CHARGED = "charged", "Charged"
CANCELLED = "cancelled", "Cancelled"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="credit_reservations")
user = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_reservations")
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="credit_reservations",
)
task = models.OneToOneField("ai.AITask", on_delete=models.CASCADE, related_name="credit_reservation")
amount = models.DecimalField(max_digits=14, decimal_places=4)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
expires_at = models.DateTimeField(null=True, blank=True)
class QuotaPolicy(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="quota_policies")
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, null=True, blank=True, related_name="quota_policies")
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="quota_policies",
)
monthly_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
project_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
per_task_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
is_active = models.BooleanField(default=True)

View File

@ -0,0 +1,53 @@
from rest_framework import serializers
from .models import CreditAccount, CreditLedger, CreditReservation, QuotaPolicy
class CreditAccountSerializer(serializers.ModelSerializer):
class Meta:
model = CreditAccount
fields = ["id", "balance", "reserved_balance", "currency", "updated_at"]
read_only_fields = fields
class CreditLedgerSerializer(serializers.ModelSerializer):
# 成员展示名:优先真实姓名 → 用户名 → 邮箱;系统流水(无 user)留空
user_label = serializers.SerializerMethodField()
class Meta:
model = CreditLedger
fields = [
"id",
"user",
"user_label",
"project",
"task",
"ledger_type",
"amount",
"balance_after",
"reason",
"metadata",
"created_at",
]
read_only_fields = fields
def get_user_label(self, obj):
user = obj.user
if user is None:
return ""
return user.first_name or user.username or user.email or ""
class CreditReservationSerializer(serializers.ModelSerializer):
class Meta:
model = CreditReservation
fields = ["id", "user", "project", "task", "amount", "status", "expires_at", "created_at"]
read_only_fields = fields
class QuotaPolicySerializer(serializers.ModelSerializer):
class Meta:
model = QuotaPolicy
fields = ["id", "user", "project", "monthly_limit", "project_limit", "per_task_limit", "is_active"]
read_only_fields = ["id"]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,93 @@
from decimal import Decimal
from django.db import transaction
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
@transaction.atomic
def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
available = account.balance - account.reserved_balance
if available < amount:
raise ValueError("insufficient credit")
account.reserved_balance += amount
account.save(update_fields=["reserved_balance", "updated_at"])
reservation = CreditReservation.objects.create(
team=team,
user=user,
project=task.project,
task=task,
amount=amount,
)
CreditLedger.objects.create(
team=team,
user=user,
project=task.project,
task=task,
ledger_type=CreditLedger.Type.RESERVE,
amount=amount,
balance_after=account.balance,
reason="AI 任务预扣额度",
)
return reservation
@transaction.atomic
def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
if reservation.status != CreditReservation.Status.ACTIVE:
return
account.reserved_balance -= reservation.amount
account.save(update_fields=["reserved_balance", "updated_at"])
reservation.status = CreditReservation.Status.RELEASED
reservation.save(update_fields=["status", "updated_at"])
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount,
balance_after=account.balance,
reason=reason or "释放预留额度",
)
@transaction.atomic
def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Decimal) -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
if reservation.status != CreditReservation.Status.ACTIVE:
raise ValueError("reservation is not active")
if actual_amount > reservation.amount:
raise ValueError("actual amount exceeds reserved amount")
account.balance -= actual_amount
account.reserved_balance -= reservation.amount
account.save(update_fields=["balance", "reserved_balance", "updated_at"])
reservation.status = CreditReservation.Status.CHARGED
reservation.save(update_fields=["status", "updated_at"])
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.CHARGE,
amount=actual_amount,
balance_after=account.balance,
reason="AI 任务扣费",
)
if reservation.amount > actual_amount:
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount - actual_amount,
balance_after=account.balance,
reason="释放未用预留额度",
)

View File

@ -0,0 +1,60 @@
from decimal import Decimal
from django.test import TestCase
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
class CreditLedgerTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="owner", password="pass")
self.team = Team.objects.create(name="Billing Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.account = CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000"))
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
self.model = ModelConfig.objects.create(
provider=self.provider,
name="doubao-seed-2-0-pro-260215",
display_name="Doubao",
capability=ModelConfig.Capability.TEXT,
)
self.task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.SCRIPT_GENERATION,
model_config=self.model,
idempotency_key="billing-test-task",
estimated_cost=Decimal("10.0000"),
)
def test_reserve_and_charge_credit(self):
reservation = reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
self.account.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.ACTIVE)
self.assertEqual(self.account.balance, Decimal("100.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("10.0000"))
charge_reserved_credit(reservation=reservation, actual_amount=Decimal("8.0000"))
self.account.refresh_from_db()
reservation.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
self.assertEqual(self.account.balance, Decimal("92.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(team=self.team).count(), 3)
def test_release_reserved_credit(self):
reservation = reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
release_credit(reservation=reservation, reason="model failed")
self.account.refresh_from_db()
reservation.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
self.assertEqual(self.account.balance, Decimal("100.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)

View File

@ -0,0 +1,10 @@
from django.urls import path
from .views import ledgers, recharge, summary, trend
urlpatterns = [
path("summary/", summary, name="billing-summary"),
path("ledgers/", ledgers, name="billing-ledgers"),
path("recharge/", recharge, name="billing-recharge"),
path("trend/", trend, name="billing-trend"),
]

View File

@ -0,0 +1,194 @@
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Sum
from django.db.models.functions import TruncDate
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.ai.models import AITask
from apps.common.api import get_current_team
from .models import CreditAccount, CreditLedger
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
# AITask.task_type → 账户页「按阶段分布」的 4 个聚合桶
_STAGE_BUCKET = {
AITask.Type.SCRIPT_GENERATION: "script",
AITask.Type.SCRIPT_OPTIMIZATION: "script",
AITask.Type.PRODUCT_IMAGE: "base",
AITask.Type.PERSON_IMAGE: "base",
AITask.Type.SCENE_IMAGE: "base",
AITask.Type.STORYBOARD: "storyboard",
AITask.Type.VIDEO_SEGMENT: "video",
AITask.Type.EXPORT: "video",
}
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def summary(request):
team = get_current_team(request.user)
account, _ = CreditAccount.objects.get_or_create(team=team)
charged = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE).aggregate(
total=Sum("amount")
)["total"] or 0
return Response(
{
"account": CreditAccountSerializer(account).data,
"charged_total": charged,
}
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def ledgers(request):
team = get_current_team(request.user)
queryset = CreditLedger.objects.filter(team=team).select_related("user", "project", "task").order_by("-created_at")
project_id = request.query_params.get("project")
user_id = request.query_params.get("user")
if project_id:
queryset = queryset.filter(project_id=project_id)
if user_id:
queryset = queryset.filter(user_id=user_id)
# 服务端分页:总数随流水增长(原先写死 [:100] 导致永远 100 条)
try:
page = max(1, int(request.query_params.get("page", 1)))
except (TypeError, ValueError):
page = 1
try:
page_size = int(request.query_params.get("page_size", 10))
except (TypeError, ValueError):
page_size = 10
page_size = max(1, min(page_size, 100))
total = queryset.count()
start = (page - 1) * page_size
rows = queryset[start:start + page_size]
return Response(
{
"count": total,
"page": page,
"page_size": page_size,
"results": CreditLedgerSerializer(rows, many=True).data,
}
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def recharge(request):
team = get_current_team(request.user)
try:
amount = Decimal(str(request.data.get("amount", "0")))
bonus = Decimal(str(request.data.get("bonus", "0")))
except (InvalidOperation, TypeError):
return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST)
if amount <= 0:
return Response({"detail": "amount must be positive"}, status=status.HTTP_400_BAD_REQUEST)
if bonus < 0:
return Response({"detail": "bonus cannot be negative"}, status=status.HTTP_400_BAD_REQUEST)
channel = str(request.data.get("channel") or "manual")[:32]
credited = amount + bonus
with transaction.atomic():
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
account.balance += credited
account.save(update_fields=["balance", "updated_at"])
ledger = CreditLedger.objects.create(
team=team,
user=request.user,
ledger_type=CreditLedger.Type.RECHARGE,
amount=credited,
balance_after=account.balance,
reason="团队充值",
metadata={"channel": channel, "paid_amount": str(amount), "bonus": str(bonus)},
)
return Response(
{
"account": CreditAccountSerializer(account).data,
"ledger": CreditLedgerSerializer(ledger).data,
},
status=status.HTTP_201_CREATED,
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def trend(request):
"""账户页消费分析:消费趋势(日/周/月可切)+ 本月按阶段/按项目分布。全部来自真实 CHARGE 流水。"""
team = get_current_team(request.user)
today = timezone.localdate()
rng = request.query_params.get("range", "day")
charges = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE)
def _daily_amounts(win_start):
rows = (
charges.filter(created_at__date__gte=win_start)
.annotate(day=TruncDate("created_at"))
.values("day")
.annotate(amount=Sum("amount"))
)
return {row["day"]: row["amount"] or Decimal("0") for row in rows}
# 按 range 选窗口与分桶:日=近 14 天 / 周=近 8 周 / 月=近 6 个自然月(缺口补 0)
series = []
if rng == "week":
monday = today - timedelta(days=today.weekday())
starts = [monday - timedelta(weeks=(7 - i)) for i in range(8)]
amt_by_day = _daily_amounts(starts[0])
for s in starts:
total = sum((amt_by_day.get(s + timedelta(days=k), Decimal("0")) for k in range(7)), Decimal("0"))
series.append({"date": s.isoformat(), "label": s.strftime("%m/%d"), "amount": str(total)})
elif rng == "month":
seq = []
y, m = today.year, today.month
for _ in range(6):
seq.append((y, m))
m -= 1
if m == 0:
m, y = 12, y - 1
seq.reverse()
amt_by_day = _daily_amounts(today.replace(year=seq[0][0], month=seq[0][1], day=1))
for yy, mm in seq:
total = sum((v for d, v in amt_by_day.items() if d.year == yy and d.month == mm), Decimal("0"))
series.append({"date": f"{yy}-{mm:02d}-01", "label": f"{mm}", "amount": str(total)})
else:
start = today - timedelta(days=13)
amt_by_day = _daily_amounts(start)
for i in range(14):
d = start + timedelta(days=i)
series.append({"date": d.isoformat(), "label": d.strftime("%m/%d"), "amount": str(amt_by_day.get(d, Decimal("0")))})
daily = series
total_14d = sum((Decimal(s["amount"]) for s in series), Decimal("0"))
peak = max((Decimal(s["amount"]) for s in series), default=Decimal("0"))
avg = (total_14d / len(series)).quantize(Decimal("0.0001")) if series else Decimal("0")
# 本月按阶段分布(task.task_type → 4 桶)
month_start = today.replace(day=1)
month_charges = charges.filter(created_at__date__gte=month_start).select_related("task")
by_stage = {"script": Decimal("0"), "base": Decimal("0"), "storyboard": Decimal("0"), "video": Decimal("0")}
project_amounts: dict[str, Decimal] = {}
for row in month_charges:
task = row.task
bucket = _STAGE_BUCKET.get(task.task_type) if task else None
if bucket:
by_stage[bucket] += row.amount
pid = str(row.project_id) if row.project_id else None
if pid:
project_amounts[pid] = project_amounts.get(pid, Decimal("0")) + row.amount
return Response(
{
"daily": daily,
"total_14d": str(total_14d),
"avg": str(avg),
"peak": str(peak),
"by_stage": {k: str(v) for k, v in by_stage.items()},
"by_project": {k: str(v) for k, v in project_amounts.items()},
}
)

View File

@ -0,0 +1 @@

Some files were not shown because too many files have changed in this diff Show More