Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 890cb9ab67 | |||
| 3fac38c5ef | |||
| aa4bdeac83 | |||
| 0873e724bf | |||
| 92826dec14 | |||
|
|
8959946241 | ||
|
|
64b0f3a1aa | ||
|
|
2242241c3b | ||
|
|
579fb7cefa | ||
|
|
099bf0e6aa | ||
|
|
603584b46b | ||
|
|
a8f4608d10 | ||
|
|
aad9bd683b | ||
|
|
8f80247e0d | ||
|
|
8bcf7615df | ||
|
|
78fd7ee13d | ||
|
|
25bf3293df | ||
| d41e487f08 | |||
| cfdcd84a30 | |||
| 2ba1058329 | |||
| e06a16e200 | |||
| df7b90934a | |||
| bbe29622c2 | |||
| 5edfa05369 | |||
| 134778dde8 | |||
| 54b57f76d0 | |||
| 086d92991e | |||
| ea335587d2 | |||
| 553014cc79 | |||
| 04335f3269 | |||
| f420af2069 | |||
| 8a783ca36f | |||
|
|
2a3ae88dc5 | ||
|
|
e3afa6659b | ||
|
|
8b8a172102 | ||
|
|
5d09ba72fd | ||
|
|
868ba69ea4 | ||
|
|
704ef9a954 | ||
|
|
e7c0a14f75 | ||
|
|
e293aa43be |
115
.claude/skills/pixel-perfect-react/SKILL.md
Normal file
115
.claude/skills/pixel-perfect-react/SKILL.md
Normal 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 自检。** 任何"我觉得这样更好"的改写,都是还原度变糙的源头。
|
||||
@ -27,6 +27,7 @@ jobs:
|
||||
echo "CR_ORG=prod" >> $GITHUB_ENV
|
||||
echo "DEPLOY_ENV=production" >> $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
|
||||
echo "IMAGE_TAG=dev-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
|
||||
echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV
|
||||
@ -35,6 +36,7 @@ jobs:
|
||||
echo "CR_ORG=dev" >> $GITHUB_ENV
|
||||
echo "DEPLOY_ENV=development" >> $GITHUB_ENV
|
||||
echo "DOMAIN_WEB=airshelf.test.airlabs.art" >> $GITHUB_ENV
|
||||
echo "DOMAIN_CORE=airshelf-web.test.airlabs.art" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to Volcano Engine CR
|
||||
@ -63,6 +65,50 @@ jobs:
|
||||
done
|
||||
[ $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
|
||||
run: |
|
||||
if ! command -v kubectl &>/dev/null; then
|
||||
@ -94,12 +140,32 @@ jobs:
|
||||
echo "Environment: ${{ env.DEPLOY_ENV }}"
|
||||
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
|
||||
|
||||
# Replace domain placeholder in ingress
|
||||
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 内网连接可能抖动)
|
||||
export KUBECTL_TIMEOUT="--request-timeout=4s"
|
||||
|
||||
@ -114,16 +180,32 @@ jobs:
|
||||
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
||||
--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/redirect-https-middleware.yaml
|
||||
|
||||
# PRD design site
|
||||
kubectl $KUBECTL_TIMEOUT apply -f k8s/web-deployment.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
|
||||
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-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; }
|
||||
echo "Attempt $attempt failed, retrying in 30s..."
|
||||
sleep 30
|
||||
@ -143,6 +225,16 @@ jobs:
|
||||
if [ -f /tmp/build.log ]; then
|
||||
BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
|
||||
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
|
||||
FAILED_STEP="deploy"
|
||||
if [ -f /tmp/deploy.log ]; then
|
||||
|
||||
47
.gitignore
vendored
47
.gitignore
vendored
@ -1,28 +1,21 @@
|
||||
# 工程文件
|
||||
node_modules/
|
||||
.next/
|
||||
.turbo/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 本地环境
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
.env*.local
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 临时
|
||||
*.tmp
|
||||
*.bak
|
||||
screenshots/
|
||||
# core 后端环境变量需要进 CI 构建,放行它(其余 .env 仍忽略)
|
||||
!core/backend/.env
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
.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
106
AGENTS.md
Normal 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
99
CLAUDE.md
Normal 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/) |
|
||||
|
||||
---
|
||||
|
||||
**违反任何规范规则,用户有权要求重做,无需解释。**
|
||||
53
README.md
53
README.md
@ -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`
|
||||
21
_archive/root-next-20260528/README.md
Normal file
21
_archive/root-next-20260528/README.md
Normal 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.
|
||||
1198
_archive/root-next-20260528/app/globals.css
Normal file
1198
_archive/root-next-20260528/app/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
37
_archive/root-next-20260528/app/layout.tsx
Normal file
37
_archive/root-next-20260528/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
_archive/root-next-20260528/app/page.tsx
Normal file
181
_archive/root-next-20260528/app/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
_archive/root-next-20260528/app/products/page.tsx
Normal file
93
_archive/root-next-20260528/app/products/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
877
_archive/root-next-20260528/app/projects/new/page.tsx
Normal file
877
_archive/root-next-20260528/app/projects/new/page.tsx
Normal 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 按此扩写。推荐 5–30 字。" },
|
||||
{ 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">推荐 5–30 字。这句话会作为 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>
|
||||
);
|
||||
}
|
||||
224
_archive/root-next-20260528/app/projects/page.tsx
Normal file
224
_archive/root-next-20260528/app/projects/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
_archive/root-next-20260528/components/GridBg.tsx
Normal file
38
_archive/root-next-20260528/components/GridBg.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
260
_archive/root-next-20260528/components/Icon.tsx
Normal file
260
_archive/root-next-20260528/components/Icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
_archive/root-next-20260528/components/Sidebar.tsx
Normal file
79
_archive/root-next-20260528/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
_archive/root-next-20260528/components/Topbar.tsx
Normal file
65
_archive/root-next-20260528/components/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
_archive/root-next-20260528/deployment-guide.md
Normal file
118
_archive/root-next-20260528/deployment-guide.md
Normal 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 分钟自动扫描卡住的任务并重新入队,无需手动干预。
|
||||
15
_archive/root-next-20260528/k8s/cert-manager-issuer.yaml
Normal file
15
_archive/root-next-20260528/k8s/cert-manager-issuer.yaml
Normal 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
|
||||
24
_archive/root-next-20260528/k8s/ingress.yaml
Normal file
24
_archive/root-next-20260528/k8s/ingress.yaml
Normal 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
|
||||
@ -0,0 +1,8 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-https
|
||||
spec:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
59
_archive/root-next-20260528/k8s/web-deployment.yaml
Normal file
59
_archive/root-next-20260528/k8s/web-deployment.yaml
Normal 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
|
||||
6
_archive/root-next-20260528/next-env.d.ts
vendored
Normal file
6
_archive/root-next-20260528/next-env.d.ts
vendored
Normal 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.
|
||||
6
_archive/root-next-20260528/next.config.mjs
Normal file
6
_archive/root-next-20260528/next.config.mjs
Normal file
@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
1744
_archive/root-next-20260528/package-lock.json
generated
Normal file
1744
_archive/root-next-20260528/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
_archive/root-next-20260528/package.json
Normal file
25
_archive/root-next-20260528/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
_archive/root-next-20260528/postcss.config.mjs
Normal file
7
_archive/root-next-20260528/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
_archive/root-next-20260528/tsconfig.json
Normal file
21
_archive/root-next-20260528/tsconfig.json
Normal 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
983
_check.html
Normal 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 按此扩写。推荐 5–30 字。',
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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">推荐 5–30 字。这句话会作为 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
702
core/ARCHITECTURE.md
Normal 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_id;Worker 负责执行、轮询、重试、写状态。
|
||||
|
||||
### 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 0:Django cache
|
||||
- DB 1:Celery broker
|
||||
- DB 2:Celery 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 2:AI 纵向闭环
|
||||
|
||||
- 脚本生成
|
||||
- 基础资产生成
|
||||
- 故事板生成
|
||||
- 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 必须从第一天按真实产品设计。
|
||||
56
core/DESIGN_PARITY_AUDIT.md
Normal file
56
core/DESIGN_PARITY_AUDIT.md
Normal 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 多段生产、轮询、采用、导出和扣费回滚。
|
||||
|
||||
734
core/FEATURE_DEVELOPMENT_ALIGNMENT.md
Normal file
734
core/FEATURE_DEVELOPMENT_ALIGNMENT.md
Normal 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 + Celery,MySQL 持久化,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、MP4,60s 成片时长允许合理浮动。
|
||||
- 下载链接有过期时间,不暴露永久私有地址。
|
||||
- 删除测试数据后,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 来源:系统内置、用户上传、还是第三方库。
|
||||
- 字幕样式默认模板数量。
|
||||
- 成片是否加水印,测试环境和正式环境是否不同。
|
||||
- 内容安全策略:由火山模型拦截、平台自审、还是两者结合。
|
||||
- 导出文件保留周期和临时文件清理周期。
|
||||
10
core/backend/.dockerignore
Normal file
10
core/backend/.dockerignore
Normal file
@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
tests/
|
||||
*.md
|
||||
staticfiles/
|
||||
24
core/backend/.env
Normal file
24
core/backend/.env
Normal 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
26
core/backend/.env.example
Normal 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
26
core/backend/Dockerfile
Normal 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
44
core/backend/README.md
Normal 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
|
||||
```
|
||||
7
core/backend/airshelf/__init__.py
Normal file
7
core/backend/airshelf/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
try:
|
||||
import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
9
core/backend/airshelf/asgi.py
Normal file
9
core/backend/airshelf/asgi.py
Normal 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()
|
||||
|
||||
11
core/backend/airshelf/celery.py
Normal file
11
core/backend/airshelf/celery.py
Normal 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()
|
||||
|
||||
1
core/backend/airshelf/settings/__init__.py
Normal file
1
core/backend/airshelf/settings/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
175
core/backend/airshelf/settings/base.py
Normal file
175
core/backend/airshelf/settings/base.py
Normal 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")
|
||||
5
core/backend/airshelf/settings/development.py
Normal file
5
core/backend/airshelf/settings/development.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .base import * # noqa: F403
|
||||
|
||||
|
||||
DEBUG = True
|
||||
|
||||
24
core/backend/airshelf/settings/production.py
Normal file
24
core/backend/airshelf/settings/production.py
Normal 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"},
|
||||
}
|
||||
13
core/backend/airshelf/settings/test.py
Normal file
13
core/backend/airshelf/settings/test.py
Normal 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
|
||||
17
core/backend/airshelf/urls.py
Normal file
17
core/backend/airshelf/urls.py
Normal 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")),
|
||||
]
|
||||
9
core/backend/airshelf/wsgi.py
Normal file
9
core/backend/airshelf/wsgi.py
Normal 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()
|
||||
|
||||
1
core/backend/apps/__init__.py
Normal file
1
core/backend/apps/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
core/backend/apps/accounts/__init__.py
Normal file
1
core/backend/apps/accounts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
25
core/backend/apps/accounts/admin.py
Normal file
25
core/backend/apps/accounts/admin.py
Normal 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")
|
||||
|
||||
7
core/backend/apps/accounts/apps.py
Normal file
7
core/backend/apps/accounts/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
|
||||
245
core/backend/apps/accounts/migrations/0001_initial.py
Normal file
245
core/backend/apps/accounts/migrations/0001_initial.py
Normal 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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/accounts/migrations/__init__.py
Normal file
1
core/backend/apps/accounts/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
97
core/backend/apps/accounts/models.py
Normal file
97
core/backend/apps/accounts/models.py
Normal 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}"
|
||||
94
core/backend/apps/accounts/serializers.py
Normal file
94
core/backend/apps/accounts/serializers.py
Normal 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)
|
||||
29
core/backend/apps/accounts/tests.py
Normal file
29
core/backend/apps/accounts/tests.py
Normal 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())
|
||||
|
||||
34
core/backend/apps/accounts/urls.py
Normal file
34
core/backend/apps/accounts/urls.py
Normal 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"),
|
||||
]
|
||||
360
core/backend/apps/accounts/views.py
Normal file
360
core/backend/apps/accounts/views.py
Normal 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})
|
||||
1
core/backend/apps/ai/__init__.py
Normal file
1
core/backend/apps/ai/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
26
core/backend/apps/ai/admin.py
Normal file
26
core/backend/apps/ai/admin.py
Normal 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")
|
||||
|
||||
7
core/backend/apps/ai/apps.py
Normal file
7
core/backend/apps/ai/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.ai"
|
||||
|
||||
73
core/backend/apps/ai/catalog.py
Normal file
73
core/backend/apps/ai/catalog.py
Normal 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",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
171
core/backend/apps/ai/migrations/0001_initial.py
Normal file
171
core/backend/apps/ai/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
78
core/backend/apps/ai/migrations/0002_initial.py
Normal file
78
core/backend/apps/ai/migrations/0002_initial.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/ai/migrations/__init__.py
Normal file
1
core/backend/apps/ai/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
102
core/backend/apps/ai/models.py
Normal file
102
core/backend/apps/ai/models.py
Normal 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}"
|
||||
|
||||
6
core/backend/apps/ai/providers/__init__.py
Normal file
6
core/backend/apps/ai/providers/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from .base import AIProvider, AIProviderResult
|
||||
from .volcano import VolcanoArkProvider
|
||||
|
||||
|
||||
__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"]
|
||||
|
||||
19
core/backend/apps/ai/providers/base.py
Normal file
19
core/backend/apps/ai/providers/base.py
Normal 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:
|
||||
...
|
||||
|
||||
173
core/backend/apps/ai/providers/volcano.py
Normal file
173
core/backend/apps/ai/providers/volcano.py
Normal 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"
|
||||
44
core/backend/apps/ai/serializers.py
Normal file
44
core/backend/apps/ai/serializers.py
Normal 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
|
||||
|
||||
800
core/backend/apps/ai/services.py
Normal file
800
core/backend/apps/ai/services.py
Normal 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
|
||||
12
core/backend/apps/ai/tasks.py
Normal file
12
core/backend/apps/ai/tasks.py
Normal 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
|
||||
|
||||
12
core/backend/apps/ai/urls.py
Normal file
12
core/backend/apps/ai/urls.py
Normal 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
|
||||
48
core/backend/apps/ai/views.py
Normal file
48
core/backend/apps/ai/views.py
Normal 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"]
|
||||
|
||||
1
core/backend/apps/assets/__init__.py
Normal file
1
core/backend/apps/assets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
22
core/backend/apps/assets/admin.py
Normal file
22
core/backend/apps/assets/admin.py
Normal 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)
|
||||
|
||||
7
core/backend/apps/assets/apps.py
Normal file
7
core/backend/apps/assets/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AssetsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.assets"
|
||||
|
||||
232
core/backend/apps/assets/migrations/0001_initial.py
Normal file
232
core/backend/apps/assets/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
42
core/backend/apps/assets/migrations/0002_initial.py
Normal file
42
core/backend/apps/assets/migrations/0002_initial.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/assets/migrations/__init__.py
Normal file
1
core/backend/apps/assets/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
100
core/backend/apps/assets/models.py
Normal file
100
core/backend/apps/assets/models.py
Normal 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)
|
||||
|
||||
87
core/backend/apps/assets/serializers.py
Normal file
87
core/backend/apps/assets/serializers.py
Normal 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)
|
||||
|
||||
52
core/backend/apps/assets/storage.py
Normal file
52
core/backend/apps/assets/storage.py
Normal 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,
|
||||
)
|
||||
11
core/backend/apps/assets/urls.py
Normal file
11
core/backend/apps/assets/urls.py
Normal 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
|
||||
61
core/backend/apps/assets/views.py
Normal file
61
core/backend/apps/assets/views.py
Normal 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)
|
||||
1
core/backend/apps/billing/__init__.py
Normal file
1
core/backend/apps/billing/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
21
core/backend/apps/billing/admin.py
Normal file
21
core/backend/apps/billing/admin.py
Normal 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)
|
||||
|
||||
7
core/backend/apps/billing/apps.py
Normal file
7
core/backend/apps/billing/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.billing"
|
||||
|
||||
277
core/backend/apps/billing/migrations/0001_initial.py
Normal file
277
core/backend/apps/billing/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/billing/migrations/__init__.py
Normal file
1
core/backend/apps/billing/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
84
core/backend/apps/billing/models.py
Normal file
84
core/backend/apps/billing/models.py
Normal 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)
|
||||
|
||||
53
core/backend/apps/billing/serializers.py
Normal file
53
core/backend/apps/billing/serializers.py
Normal 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"]
|
||||
|
||||
1
core/backend/apps/billing/services/__init__.py
Normal file
1
core/backend/apps/billing/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
93
core/backend/apps/billing/services/ledger.py
Normal file
93
core/backend/apps/billing/services/ledger.py
Normal 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="释放未用预留额度",
|
||||
)
|
||||
|
||||
60
core/backend/apps/billing/tests.py
Normal file
60
core/backend/apps/billing/tests.py
Normal 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)
|
||||
|
||||
10
core/backend/apps/billing/urls.py
Normal file
10
core/backend/apps/billing/urls.py
Normal 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"),
|
||||
]
|
||||
194
core/backend/apps/billing/views.py
Normal file
194
core/backend/apps/billing/views.py
Normal 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()},
|
||||
}
|
||||
)
|
||||
1
core/backend/apps/common/__init__.py
Normal file
1
core/backend/apps/common/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user