diff --git a/.gitignore b/.gitignore index c7ed5a2..6590034 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ node_modules .next out +dist .env*.local +.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 diff --git a/AGENTS.md b/AGENTS.md index 1edf6c1..8360273 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,13 @@ - **设计稿优先** · 写代码前必须先读 [电商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),不要复制到本文件。 + --- ## 用户偏好 diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md new file mode 100644 index 0000000..312eea1 --- /dev/null +++ b/core/ARCHITECTURE.md @@ -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 必须从第一天按真实产品设计。 diff --git a/core/DESIGN_PARITY_AUDIT.md b/core/DESIGN_PARITY_AUDIT.md new file mode 100644 index 0000000..758926d --- /dev/null +++ b/core/DESIGN_PARITY_AUDIT.md @@ -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 多段生产、轮询、采用、导出和扣费回滚。 + diff --git a/core/FEATURE_DEVELOPMENT_ALIGNMENT.md b/core/FEATURE_DEVELOPMENT_ALIGNMENT.md new file mode 100644 index 0000000..0dcd393 --- /dev/null +++ b/core/FEATURE_DEVELOPMENT_ALIGNMENT.md @@ -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 + +completed | partial | failed +一句话说明本轮测试结论 + +- summary.md: done | partial | skipped +- screenshots: done | partial | skipped +- traces: done | partial | skipped + + +- [x] 真实浏览器点击: PASS +- [x] 控制台错误检查: PASS +- [x] 真实数据验证: PASS +- [x] 账本一致性核对: PASS + +无,或列出需要上报的问题 + +``` + +## 17. 待确认项 + +这些项不阻塞开发,但进入正式计费和对外交付前需要确认: + +- 火山各模型的正式 endpoint、并发限制、回调能力、计费口径。 +- 60s 视频默认脚本分段策略:固定 4 段,还是按脚本语义自动切 4 段。 +- 失败重试的免费次数和计费边界。 +- BGM 来源:系统内置、用户上传、还是第三方库。 +- 字幕样式默认模板数量。 +- 成片是否加水印,测试环境和正式环境是否不同。 +- 内容安全策略:由火山模型拦截、平台自审、还是两者结合。 +- 导出文件保留周期和临时文件清理周期。 diff --git a/core/backend/.env.example b/core/backend/.env.example new file mode 100644 index 0000000..2437815 --- /dev/null +++ b/core/backend/.env.example @@ -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 diff --git a/core/backend/README.md b/core/backend/README.md new file mode 100644 index 0000000..1f3abc7 --- /dev/null +++ b/core/backend/README.md @@ -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 +``` diff --git a/core/backend/airshelf/__init__.py b/core/backend/airshelf/__init__.py new file mode 100644 index 0000000..8a716c8 --- /dev/null +++ b/core/backend/airshelf/__init__.py @@ -0,0 +1,7 @@ +try: + import pymysql + + pymysql.install_as_MySQLdb() +except Exception: + pass + diff --git a/core/backend/airshelf/asgi.py b/core/backend/airshelf/asgi.py new file mode 100644 index 0000000..f973dc1 --- /dev/null +++ b/core/backend/airshelf/asgi.py @@ -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() + diff --git a/core/backend/airshelf/celery.py b/core/backend/airshelf/celery.py new file mode 100644 index 0000000..8d2aa3f --- /dev/null +++ b/core/backend/airshelf/celery.py @@ -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() + diff --git a/core/backend/airshelf/settings/__init__.py b/core/backend/airshelf/settings/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/airshelf/settings/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py new file mode 100644 index 0000000..ab4ec9e --- /dev/null +++ b/core/backend/airshelf/settings/base.py @@ -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") diff --git a/core/backend/airshelf/settings/development.py b/core/backend/airshelf/settings/development.py new file mode 100644 index 0000000..d6ea858 --- /dev/null +++ b/core/backend/airshelf/settings/development.py @@ -0,0 +1,5 @@ +from .base import * # noqa: F403 + + +DEBUG = True + diff --git a/core/backend/airshelf/settings/production.py b/core/backend/airshelf/settings/production.py new file mode 100644 index 0000000..2277a85 --- /dev/null +++ b/core/backend/airshelf/settings/production.py @@ -0,0 +1,8 @@ +from .base import * # noqa: F403 + + +DEBUG = False +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + diff --git a/core/backend/airshelf/settings/test.py b/core/backend/airshelf/settings/test.py new file mode 100644 index 0000000..12e8dd2 --- /dev/null +++ b/core/backend/airshelf/settings/test.py @@ -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 diff --git a/core/backend/airshelf/urls.py b/core/backend/airshelf/urls.py new file mode 100644 index 0000000..d06a4a3 --- /dev/null +++ b/core/backend/airshelf/urls.py @@ -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")), +] diff --git a/core/backend/airshelf/wsgi.py b/core/backend/airshelf/wsgi.py new file mode 100644 index 0000000..37f943e --- /dev/null +++ b/core/backend/airshelf/wsgi.py @@ -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() + diff --git a/core/backend/apps/__init__.py b/core/backend/apps/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/accounts/__init__.py b/core/backend/apps/accounts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/accounts/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/accounts/admin.py b/core/backend/apps/accounts/admin.py new file mode 100644 index 0000000..3dcc0f7 --- /dev/null +++ b/core/backend/apps/accounts/admin.py @@ -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") + diff --git a/core/backend/apps/accounts/apps.py b/core/backend/apps/accounts/apps.py new file mode 100644 index 0000000..bc75bc7 --- /dev/null +++ b/core/backend/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + diff --git a/core/backend/apps/accounts/migrations/0001_initial.py b/core/backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..5cd43e9 --- /dev/null +++ b/core/backend/apps/accounts/migrations/0001_initial.py @@ -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")}, + }, + ), + ] diff --git a/core/backend/apps/accounts/migrations/__init__.py b/core/backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/accounts/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/accounts/models.py b/core/backend/apps/accounts/models.py new file mode 100644 index 0000000..af64bff --- /dev/null +++ b/core/backend/apps/accounts/models.py @@ -0,0 +1,59 @@ +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 + + +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}" diff --git a/core/backend/apps/accounts/serializers.py b/core/backend/apps/accounts/serializers.py new file mode 100644 index 0000000..5ec237d --- /dev/null +++ b/core/backend/apps/accounts/serializers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers + +from apps.billing.models import CreditAccount + +from .models import Team, TeamMember, User + + +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 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) + + class Meta: + model = TeamMember + fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"] + read_only_fields = ["id", "team", "user", "status"] + + +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) diff --git a/core/backend/apps/accounts/tests.py b/core/backend/apps/accounts/tests.py new file mode 100644 index 0000000..836e8ee --- /dev/null +++ b/core/backend/apps/accounts/tests.py @@ -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()) + diff --git a/core/backend/apps/accounts/urls.py b/core/backend/apps/accounts/urls.py new file mode 100644 index 0000000..df4b81e --- /dev/null +++ b/core/backend/apps/accounts/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from .views import login, logout, me, register, team_member_detail, team_member_password, team_members + + +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("team/members/", team_members, name="team-members"), + path("team/members//", team_member_detail, name="team-member-detail"), + path("team/members//password/", team_member_password, name="team-member-password"), +] diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py new file mode 100644 index 0000000..e964528 --- /dev/null +++ b/core/backend/apps/accounts/views.py @@ -0,0 +1,170 @@ +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, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from apps.common.api import get_current_team + +from .models import TeamMember, User +from .serializers import LoginSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserSerializer + + +def auth_payload(user, team, token): + return { + "token": token.key, + "user": UserSerializer(user).data, + "team": TeamSerializer(team).data, + } + + +@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"]) + 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) + 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"]) +@permission_classes([IsAuthenticated]) +def me(request): + team = get_current_team(request.user) + return Response( + { + "user": UserSerializer(request.user).data, + "team": TeamSerializer(team).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}) + + +@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") + return Response(TeamMemberSerializer(members, many=True).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) diff --git a/core/backend/apps/ai/__init__.py b/core/backend/apps/ai/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/ai/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/ai/admin.py b/core/backend/apps/ai/admin.py new file mode 100644 index 0000000..7847ad1 --- /dev/null +++ b/core/backend/apps/ai/admin.py @@ -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") + diff --git a/core/backend/apps/ai/apps.py b/core/backend/apps/ai/apps.py new file mode 100644 index 0000000..a89e3fc --- /dev/null +++ b/core/backend/apps/ai/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.ai" + diff --git a/core/backend/apps/ai/catalog.py b/core/backend/apps/ai/catalog.py new file mode 100644 index 0000000..93a978b --- /dev/null +++ b/core/backend/apps/ai/catalog.py @@ -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", + }, + }, +] + diff --git a/core/backend/apps/ai/migrations/0001_initial.py b/core/backend/apps/ai/migrations/0001_initial.py new file mode 100644 index 0000000..4c65a48 --- /dev/null +++ b/core/backend/apps/ai/migrations/0001_initial.py @@ -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, + ), + ), + ], + ), + ] diff --git a/core/backend/apps/ai/migrations/0002_initial.py b/core/backend/apps/ai/migrations/0002_initial.py new file mode 100644 index 0000000..9094fdf --- /dev/null +++ b/core/backend/apps/ai/migrations/0002_initial.py @@ -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")}, + ), + ] diff --git a/core/backend/apps/ai/migrations/__init__.py b/core/backend/apps/ai/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/ai/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py new file mode 100644 index 0000000..c1e9b86 --- /dev/null +++ b/core/backend/apps/ai/models.py @@ -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}" + diff --git a/core/backend/apps/ai/providers/__init__.py b/core/backend/apps/ai/providers/__init__.py new file mode 100644 index 0000000..c61e4b6 --- /dev/null +++ b/core/backend/apps/ai/providers/__init__.py @@ -0,0 +1,6 @@ +from .base import AIProvider, AIProviderResult +from .volcano import VolcanoArkProvider + + +__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"] + diff --git a/core/backend/apps/ai/providers/base.py b/core/backend/apps/ai/providers/base.py new file mode 100644 index 0000000..7ca0e36 --- /dev/null +++ b/core/backend/apps/ai/providers/base.py @@ -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: + ... + diff --git a/core/backend/apps/ai/providers/volcano.py b/core/backend/apps/ai/providers/volcano.py new file mode 100644 index 0000000..319a6e7 --- /dev/null +++ b/core/backend/apps/ai/providers/volcano.py @@ -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" diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py new file mode 100644 index 0000000..5d9ef01 --- /dev/null +++ b/core/backend/apps/ai/serializers.py @@ -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 + diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py new file mode 100644 index 0000000..8ab5b19 --- /dev/null +++ b/core/backend/apps/ai/services.py @@ -0,0 +1,421 @@ +import uuid +from decimal import Decimal +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) -> list[str]: + blocks = [line.strip() for line in content.splitlines() if line.strip()] + if len(blocks) >= 4: + return blocks[:4] + if not content.strip(): + return [""] * 4 + return [content.strip()] + [""] * (4 - len(blocks or [content])) + + +@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 _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, + ) + 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 generate_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion: + 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") + model_config = get_default_model(ModelConfig.Capability.IMAGE) + if model_config is None: + raise ValueError("no active image model configured") + + storyboard = StoryboardVersion.objects.create(project=project, prompt=prompt) + provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None) + for segment in adopted_script.segments.all(): + 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": segment.visual_prompt}, + ) + reservation = task.credit_reservation + try: + response = provider.image_generation( + model=model_config.name, + endpoint=model_config.endpoint, + prompt=f"{prompt}\n{segment.visual_prompt}".strip(), + ) + 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=storyboard, + script_segment=segment, + asset=asset, + sort_order=segment.sort_order, + prompt=segment.visual_prompt, + ) + 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 + storyboard.is_adopted = True + storyboard.save(update_fields=["is_adopted", "updated_at"]) + return storyboard + + +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 + 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": prompt, + "duration": video_segment.target_duration_seconds, + "ratio": "9:16", + "video_segment_id": str(video_segment.id), + }, + ) + try: + provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None) + response = provider.create_video_task( + model=model_config.name, + endpoint=model_config.endpoint, + prompt=prompt, + duration=video_segment.target_duration_seconds, + ratio="9:16", + resolution="720p", + ) + 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: + 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") + + 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) diff --git a/core/backend/apps/ai/tasks.py b/core/backend/apps/ai/tasks.py new file mode 100644 index 0000000..f96ea7a --- /dev/null +++ b/core/backend/apps/ai/tasks.py @@ -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 + diff --git a/core/backend/apps/ai/urls.py b/core/backend/apps/ai/urls.py new file mode 100644 index 0000000..16ce9a5 --- /dev/null +++ b/core/backend/apps/ai/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +from .views import AITaskViewSet, ModelConfigViewSet + +router = DefaultRouter() +router.register("tasks", AITaskViewSet, basename="ai-task") +router.register("models", ModelConfigViewSet, basename="model-config") + +urlpatterns = router.urls diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py new file mode 100644 index 0000000..a632e92 --- /dev/null +++ b/core/backend/apps/ai/views.py @@ -0,0 +1,21 @@ +from rest_framework.viewsets import ReadOnlyModelViewSet + +from apps.common.api import TeamScopedViewSetMixin + +from .models import AITask, ModelConfig +from .serializers import AITaskSerializer, ModelConfigSerializer + + +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"] + diff --git a/core/backend/apps/assets/__init__.py b/core/backend/apps/assets/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/assets/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/assets/admin.py b/core/backend/apps/assets/admin.py new file mode 100644 index 0000000..ecd92e0 --- /dev/null +++ b/core/backend/apps/assets/admin.py @@ -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) + diff --git a/core/backend/apps/assets/apps.py b/core/backend/apps/assets/apps.py new file mode 100644 index 0000000..c8cbf3c --- /dev/null +++ b/core/backend/apps/assets/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AssetsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.assets" + diff --git a/core/backend/apps/assets/migrations/0001_initial.py b/core/backend/apps/assets/migrations/0001_initial.py new file mode 100644 index 0000000..c924d93 --- /dev/null +++ b/core/backend/apps/assets/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/core/backend/apps/assets/migrations/0002_initial.py b/core/backend/apps/assets/migrations/0002_initial.py new file mode 100644 index 0000000..f259284 --- /dev/null +++ b/core/backend/apps/assets/migrations/0002_initial.py @@ -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")}, + ), + ] diff --git a/core/backend/apps/assets/migrations/__init__.py b/core/backend/apps/assets/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/assets/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/assets/models.py b/core/backend/apps/assets/models.py new file mode 100644 index 0000000..b758766 --- /dev/null +++ b/core/backend/apps/assets/models.py @@ -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) + diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py new file mode 100644 index 0000000..c15f82e --- /dev/null +++ b/core/backend/apps/assets/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + +from .models import Asset, AssetFile + + +class AssetFileSerializer(serializers.ModelSerializer): + class Meta: + model = AssetFile + fields = [ + "id", + "object_key", + "bucket", + "content_type", + "size_bytes", + "width", + "height", + "duration_ms", + "preview_url", + "is_primary", + ] + read_only_fields = fields + + +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", + "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) + diff --git a/core/backend/apps/assets/storage.py b/core/backend/apps/assets/storage.py new file mode 100644 index 0000000..085fe5e --- /dev/null +++ b/core/backend/apps/assets/storage.py @@ -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, + ) diff --git a/core/backend/apps/assets/urls.py b/core/backend/apps/assets/urls.py new file mode 100644 index 0000000..78cdd43 --- /dev/null +++ b/core/backend/apps/assets/urls.py @@ -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 diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py new file mode 100644 index 0000000..829ec08 --- /dev/null +++ b/core/backend/apps/assets/views.py @@ -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) diff --git a/core/backend/apps/billing/__init__.py b/core/backend/apps/billing/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/billing/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/billing/admin.py b/core/backend/apps/billing/admin.py new file mode 100644 index 0000000..a9fdaf1 --- /dev/null +++ b/core/backend/apps/billing/admin.py @@ -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) + diff --git a/core/backend/apps/billing/apps.py b/core/backend/apps/billing/apps.py new file mode 100644 index 0000000..47a5e0b --- /dev/null +++ b/core/backend/apps/billing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.billing" + diff --git a/core/backend/apps/billing/migrations/0001_initial.py b/core/backend/apps/billing/migrations/0001_initial.py new file mode 100644 index 0000000..da6580e --- /dev/null +++ b/core/backend/apps/billing/migrations/0001_initial.py @@ -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", + ), + ], + }, + ), + ] diff --git a/core/backend/apps/billing/migrations/__init__.py b/core/backend/apps/billing/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/billing/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/billing/models.py b/core/backend/apps/billing/models.py new file mode 100644 index 0000000..8b566f0 --- /dev/null +++ b/core/backend/apps/billing/models.py @@ -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) + diff --git a/core/backend/apps/billing/serializers.py b/core/backend/apps/billing/serializers.py new file mode 100644 index 0000000..ac501aa --- /dev/null +++ b/core/backend/apps/billing/serializers.py @@ -0,0 +1,43 @@ +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): + class Meta: + model = CreditLedger + fields = [ + "id", + "user", + "project", + "task", + "ledger_type", + "amount", + "balance_after", + "reason", + "metadata", + "created_at", + ] + read_only_fields = fields + + +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"] + diff --git a/core/backend/apps/billing/services/__init__.py b/core/backend/apps/billing/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/billing/services/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/billing/services/ledger.py b/core/backend/apps/billing/services/ledger.py new file mode 100644 index 0000000..d6b359e --- /dev/null +++ b/core/backend/apps/billing/services/ledger.py @@ -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="reserve ai task credit", + ) + 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 "release reserved credit", + ) + + +@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="charge ai task credit", + ) + 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="release unused reserved credit", + ) + diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py new file mode 100644 index 0000000..17007e3 --- /dev/null +++ b/core/backend/apps/billing/tests.py @@ -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) + diff --git a/core/backend/apps/billing/urls.py b/core/backend/apps/billing/urls.py new file mode 100644 index 0000000..235b052 --- /dev/null +++ b/core/backend/apps/billing/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import ledgers, recharge, summary + +urlpatterns = [ + path("summary/", summary, name="billing-summary"), + path("ledgers/", ledgers, name="billing-ledgers"), + path("recharge/", recharge, name="billing-recharge"), +] diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py new file mode 100644 index 0000000..3c3c014 --- /dev/null +++ b/core/backend/apps/billing/views.py @@ -0,0 +1,80 @@ +from decimal import Decimal, InvalidOperation + +from django.db import transaction +from django.db.models import Sum +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.common.api import get_current_team + +from .models import CreditAccount, CreditLedger +from .serializers import CreditAccountSerializer, CreditLedgerSerializer + + +@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) + return Response(CreditLedgerSerializer(queryset[:100], 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, + ) diff --git a/core/backend/apps/common/__init__.py b/core/backend/apps/common/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/common/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/common/api.py b/core/backend/apps/common/api.py new file mode 100644 index 0000000..3eac9ae --- /dev/null +++ b/core/backend/apps/common/api.py @@ -0,0 +1,23 @@ +from rest_framework.exceptions import PermissionDenied + + +def get_current_team(user): + membership = user.team_memberships.filter(status="active").select_related("team").first() + if not membership: + raise PermissionDenied("current user has no active team") + return membership.team + + +class TeamScopedViewSetMixin: + team_field = "team" + + def get_team(self): + return get_current_team(self.request.user) + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(**{self.team_field: self.get_team()}) + + def perform_create(self, serializer): + serializer.save(team=self.get_team(), created_by=self.request.user) + diff --git a/core/backend/apps/common/apps.py b/core/backend/apps/common/apps.py new file mode 100644 index 0000000..9a08230 --- /dev/null +++ b/core/backend/apps/common/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CommonConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.common" + diff --git a/core/backend/apps/common/management/__init__.py b/core/backend/apps/common/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/common/management/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/common/management/commands/__init__.py b/core/backend/apps/common/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/common/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/common/management/commands/bootstrap_volcano_models.py b/core/backend/apps/common/management/commands/bootstrap_volcano_models.py new file mode 100644 index 0000000..4baf569 --- /dev/null +++ b/core/backend/apps/common/management/commands/bootstrap_volcano_models.py @@ -0,0 +1,36 @@ +from django.core.management.base import BaseCommand + +from apps.ai.catalog import VOLCANO_MODELS, VOLCANO_PROVIDER +from apps.ai.models import ModelConfig, ModelProvider + + +class Command(BaseCommand): + help = "Create or update default Volcano model provider and model configs." + + def handle(self, *args, **options): + provider, _ = ModelProvider.objects.update_or_create( + name=VOLCANO_PROVIDER["name"], + defaults={ + "display_name": VOLCANO_PROVIDER["display_name"], + "base_url": VOLCANO_PROVIDER["base_url"], + "status": ModelProvider.Status.ACTIVE, + }, + ) + + count = 0 + for item in VOLCANO_MODELS: + ModelConfig.objects.update_or_create( + provider=provider, + name=item["name"], + capability=item["capability"], + defaults={ + "display_name": item["display_name"], + "endpoint": item["endpoint"], + "status": ModelConfig.Status.ACTIVE, + "metadata": item["metadata"], + }, + ) + count += 1 + + self.stdout.write(self.style.SUCCESS(f"Bootstrapped {count} Volcano model configs.")) + diff --git a/core/backend/apps/common/migrations/__init__.py b/core/backend/apps/common/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/common/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/common/models.py b/core/backend/apps/common/models.py new file mode 100644 index 0000000..cc393f3 --- /dev/null +++ b/core/backend/apps/common/models.py @@ -0,0 +1,33 @@ +import uuid + +from django.db import models + + +class UUIDModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + +class TimeStampedModel(UUIDModel): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class TeamOwnedModel(TimeStampedModel): + team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="%(class)s_set") + created_by = models.ForeignKey( + "accounts.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_%(class)s_set", + ) + + class Meta: + abstract = True + diff --git a/core/backend/apps/common/views.py b/core/backend/apps/common/views.py new file mode 100644 index 0000000..eef1fb4 --- /dev/null +++ b/core/backend/apps/common/views.py @@ -0,0 +1,6 @@ +from django.http import JsonResponse + + +def health_check(request): + return JsonResponse({"status": "ok", "service": "airshelf-backend"}) + diff --git a/core/backend/apps/ops/__init__.py b/core/backend/apps/ops/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/ops/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/ops/admin.py b/core/backend/apps/ops/admin.py new file mode 100644 index 0000000..edd62ba --- /dev/null +++ b/core/backend/apps/ops/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from .models import Notification + + +admin.site.site_header = "AirShelf Ops" +admin.site.site_title = "AirShelf Ops" +admin.site.index_title = "Operations" + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ("title", "team", "recipient", "notification_type", "priority", "is_read", "created_at") + list_filter = ("notification_type", "priority", "is_read", "archived_at") + search_fields = ("title", "brief", "body", "source", "dedupe_key") + readonly_fields = ("created_at", "updated_at", "read_at", "archived_at") diff --git a/core/backend/apps/ops/apps.py b/core/backend/apps/ops/apps.py new file mode 100644 index 0000000..d43e5b3 --- /dev/null +++ b/core/backend/apps/ops/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OpsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.ops" + diff --git a/core/backend/apps/ops/migrations/0001_initial.py b/core/backend/apps/ops/migrations/0001_initial.py new file mode 100644 index 0000000..2bb5edc --- /dev/null +++ b/core/backend/apps/ops/migrations/0001_initial.py @@ -0,0 +1,105 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("accounts", "0001_initial"), + ("projects", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + 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)), + ( + "notification_type", + models.CharField( + choices=[("task", "Task"), ("team", "Team"), ("billing", "Billing"), ("system", "System")], + default="system", + max_length=24, + ), + ), + ( + "priority", + models.CharField( + choices=[("ok", "OK"), ("warn", "Warn"), ("err", "Error"), ("info", "Info")], + default="info", + max_length=24, + ), + ), + ("title", models.CharField(max_length=200)), + ("brief", models.CharField(blank=True, max_length=300)), + ("body", models.TextField(blank=True)), + ("source", models.CharField(blank=True, max_length=120)), + ("stage", models.CharField(blank=True, max_length=120)), + ("owner_label", models.CharField(blank=True, max_length=120)), + ("cost_label", models.CharField(blank=True, max_length=64)), + ("related_url", models.CharField(blank=True, max_length=300)), + ("dedupe_key", models.CharField(blank=True, max_length=160)), + ("is_read", models.BooleanField(default=False)), + ("read_at", models.DateTimeField(blank=True, null=True)), + ("archived_at", models.DateTimeField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "project", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="notifications", + to="projects.project", + ), + ), + ( + "recipient", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="accounts.team", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="notification", + index=models.Index(fields=["team", "recipient", "is_read", "-created_at"], name="ops_notific_team_id_17a7ca_idx"), + ), + migrations.AddIndex( + model_name="notification", + index=models.Index(fields=["team", "archived_at", "-created_at"], name="ops_notific_team_id_691eaf_idx"), + ), + migrations.AddIndex( + model_name="notification", + index=models.Index(fields=["team", "dedupe_key"], name="ops_notific_team_id_8acdf4_idx"), + ), + migrations.AddConstraint( + model_name="notification", + constraint=models.UniqueConstraint( + condition=~models.Q(dedupe_key=""), + fields=("team", "dedupe_key"), + name="ops_notification_team_dedupe_key_unique", + ), + ), + ] diff --git a/core/backend/apps/ops/migrations/0002_remove_notification_ops_notification_team_dedupe_key_unique_and_more.py b/core/backend/apps/ops/migrations/0002_remove_notification_ops_notification_team_dedupe_key_unique_and_more.py new file mode 100644 index 0000000..d1ecebb --- /dev/null +++ b/core/backend/apps/ops/migrations/0002_remove_notification_ops_notification_team_dedupe_key_unique_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.15 on 2026-06-01 09:15 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0001_initial"), + ("ops", "0001_initial"), + ("projects", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="notification", + name="ops_notification_team_dedupe_key_unique", + ), + migrations.AlterField( + model_name="notification", + name="dedupe_key", + field=models.CharField(blank=True, max_length=160, null=True), + ), + migrations.AddConstraint( + model_name="notification", + constraint=models.UniqueConstraint( + fields=("team", "dedupe_key"), + name="ops_notification_team_dedupe_key_unique", + ), + ), + ] diff --git a/core/backend/apps/ops/migrations/__init__.py b/core/backend/apps/ops/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/ops/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/ops/models.py b/core/backend/apps/ops/models.py new file mode 100644 index 0000000..13612b1 --- /dev/null +++ b/core/backend/apps/ops/models.py @@ -0,0 +1,84 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone + +from apps.common.models import TimeStampedModel + + +class Notification(TimeStampedModel): + class Type(models.TextChoices): + TASK = "task", "Task" + TEAM = "team", "Team" + BILLING = "billing", "Billing" + SYSTEM = "system", "System" + + class Priority(models.TextChoices): + OK = "ok", "OK" + WARN = "warn", "Warn" + ERR = "err", "Error" + INFO = "info", "Info" + + team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="notifications") + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="notifications", + ) + project = models.ForeignKey( + "projects.Project", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="notifications", + ) + notification_type = models.CharField(max_length=24, choices=Type.choices, default=Type.SYSTEM) + priority = models.CharField(max_length=24, choices=Priority.choices, default=Priority.INFO) + title = models.CharField(max_length=200) + brief = models.CharField(max_length=300, blank=True) + body = models.TextField(blank=True) + source = models.CharField(max_length=120, blank=True) + stage = models.CharField(max_length=120, blank=True) + owner_label = models.CharField(max_length=120, blank=True) + cost_label = models.CharField(max_length=64, blank=True) + related_url = models.CharField(max_length=300, blank=True) + dedupe_key = models.CharField(max_length=160, blank=True, null=True) + is_read = models.BooleanField(default=False) + read_at = models.DateTimeField(null=True, blank=True) + archived_at = models.DateTimeField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["team", "recipient", "is_read", "-created_at"]), + models.Index(fields=["team", "archived_at", "-created_at"]), + models.Index(fields=["team", "dedupe_key"]), + ] + constraints = [ + models.UniqueConstraint( + fields=["team", "dedupe_key"], + name="ops_notification_team_dedupe_key_unique", + ) + ] + + def mark_read(self): + if not self.is_read: + self.is_read = True + self.read_at = timezone.now() + self.save(update_fields=["is_read", "read_at", "updated_at"]) + + def mark_unread(self): + if self.is_read or self.read_at: + self.is_read = False + self.read_at = None + self.save(update_fields=["is_read", "read_at", "updated_at"]) + + def archive(self): + if self.archived_at is None: + self.archived_at = timezone.now() + self.save(update_fields=["archived_at", "updated_at"]) + + def __str__(self) -> str: + return self.title diff --git a/core/backend/apps/ops/serializers.py b/core/backend/apps/ops/serializers.py new file mode 100644 index 0000000..003259d --- /dev/null +++ b/core/backend/apps/ops/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + type = serializers.CharField(source="notification_type", read_only=True) + unread = serializers.SerializerMethodField() + project_name = serializers.CharField(source="project.name", read_only=True) + + class Meta: + model = Notification + fields = [ + "id", + "type", + "notification_type", + "priority", + "title", + "brief", + "body", + "source", + "project", + "project_name", + "stage", + "owner_label", + "cost_label", + "related_url", + "is_read", + "unread", + "read_at", + "archived_at", + "metadata", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "type", + "project_name", + "read_at", + "archived_at", + "created_at", + "updated_at", + ] + + def get_unread(self, obj): + return not obj.is_read diff --git a/core/backend/apps/ops/urls.py b/core/backend/apps/ops/urls.py new file mode 100644 index 0000000..da422e5 --- /dev/null +++ b/core/backend/apps/ops/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +from .views import NotificationViewSet + + +router = DefaultRouter() +router.register("notifications", NotificationViewSet, basename="notification") + +urlpatterns = router.urls diff --git a/core/backend/apps/ops/views.py b/core/backend/apps/ops/views.py new file mode 100644 index 0000000..7b440b3 --- /dev/null +++ b/core/backend/apps/ops/views.py @@ -0,0 +1,167 @@ +from django.db.models import Q +from django.utils import timezone +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.assets.models import Asset +from apps.billing.models import CreditAccount +from apps.common.api import TeamScopedViewSetMixin +from apps.projects.models import Project + +from .models import Notification +from .serializers import NotificationSerializer + + +def project_stage_label(project): + return { + "script": "Stage 1 · 脚本", + "base_assets": "Stage 2 · 基础资产", + "storyboard": "Stage 3 · 故事板", + "video": "Stage 4 · 视频", + "export": "Stage 5 · 导出", + }.get(project.current_stage, "Stage 1 · 脚本") + + +def project_priority(project): + if project.status == Project.Status.COMPLETED: + return Notification.Priority.OK + if project.status == Project.Status.FAILED: + return Notification.Priority.ERR + return Notification.Priority.INFO + + +def ensure_team_notifications(team, user): + def create_once(dedupe_key, **payload): + Notification.objects.get_or_create( + team=team, + recipient=user, + dedupe_key=dedupe_key, + defaults=payload, + ) + + create_once( + "system:welcome", + notification_type=Notification.Type.SYSTEM, + priority=Notification.Priority.INFO, + title="团队已接入 AirShelf", + brief="真实消息中心已启用,状态会写入 Django 数据库。", + body="消息已从演示数据切换为团队级通知表。已读、未读、归档等操作都会持久化保存。", + source="Airshelf 系统", + stage="系统公告", + owner_label="系统", + cost_label="-", + related_url="settings.html#sec-notify", + ) + + for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]: + product_title = project.product.title if project.product_id else "未绑定商品" + create_once( + f"project:{project.id}:status:{project.status}:{project.current_stage}", + notification_type=Notification.Type.TASK, + priority=project_priority(project), + title=f"项目「{project.name}」状态更新", + brief=f"{product_title} · {project_stage_label(project)} · {project.get_status_display()}", + body=f"项目「{project.name}」当前处于 {project_stage_label(project)}。这条消息来自 Django 项目表,刷新后状态会保持一致。", + source="视频项目", + project=project, + stage=project_stage_label(project), + owner_label=project.created_by.username if project.created_by_id else "成员", + cost_label="-", + related_url=f"pipeline.html?project_id={project.id}", + metadata={"status": project.status, "current_stage": project.current_stage}, + ) + + for asset in Asset.objects.filter(team=team).select_related("created_by").order_by("-updated_at")[:3]: + create_once( + f"asset:{asset.id}:created", + notification_type=Notification.Type.TASK, + priority=Notification.Priority.OK, + title=f"资产「{asset.name}」已加入资产库", + brief=f"{asset.get_category_display()} · {asset.get_asset_type_display()}", + body="资产记录来自真实资产表。后续上传、AI 生成、导出成片都可以在这里形成团队通知。", + source="资产库", + stage="资产入库", + owner_label=asset.created_by.username if asset.created_by_id else "成员", + cost_label="-", + related_url="library.html", + metadata={"asset_id": str(asset.id), "category": asset.category, "asset_type": asset.asset_type}, + ) + + account, _ = CreditAccount.objects.get_or_create(team=team) + if account.balance <= 100: + create_once( + f"billing:low-balance:{account.id}", + notification_type=Notification.Type.BILLING, + priority=Notification.Priority.WARN, + title="团队余额低于预警线", + brief=f"当前余额 ¥{account.balance:.2f},建议及时充值。", + body="余额低于 100 元时系统会生成预警通知。充值或调低成员额度后可在消费页查看最新账本。", + source="计费中心", + stage="余额监控", + owner_label="系统", + cost_label=f"¥{account.balance:.2f}", + related_url="account.html", + ) + + +class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet): + serializer_class = NotificationSerializer + queryset = Notification.objects.select_related("team", "recipient", "project").all() + search_fields = ["title", "brief", "body", "source", "stage"] + ordering_fields = ["created_at", "updated_at"] + ordering = ["-created_at"] + + def get_queryset(self): + queryset = super().get_queryset().filter(archived_at__isnull=True) + user = self.request.user + queryset = queryset.filter(Q(recipient=user) | Q(recipient__isnull=True)) + notification_type = self.request.query_params.get("type") + if notification_type and notification_type not in {"all", "unread"}: + queryset = queryset.filter(notification_type=notification_type) + if self.request.query_params.get("unread") in {"1", "true", "yes"}: + queryset = queryset.filter(is_read=False) + return queryset + + def list(self, request, *args, **kwargs): + ensure_team_notifications(self.get_team(), request.user) + response = super().list(request, *args, **kwargs) + data = response.data + unread_count = self.get_queryset().filter(is_read=False).count() + if isinstance(data, dict): + data["unread_count"] = unread_count + return response + + def perform_create(self, serializer): + serializer.save(team=self.get_team(), recipient=self.request.user) + + @action(detail=False, methods=["post"], url_path="mark-all-read") + def mark_all_read(self, request): + now = timezone.now() + count = self.get_queryset().filter(is_read=False).update(is_read=True, read_at=now, updated_at=now) + return Response({"updated": count, "unread_count": self.get_queryset().filter(is_read=False).count()}) + + @action(detail=False, methods=["post"], url_path="mark-all-unread") + def mark_all_unread(self, request): + now = timezone.now() + count = self.get_queryset().filter(is_read=True).update(is_read=False, read_at=None, updated_at=now) + return Response({"updated": count, "unread_count": self.get_queryset().filter(is_read=False).count()}) + + @action(detail=True, methods=["post"], url_path="mark-read") + def mark_read(self, request, pk=None): + notification = self.get_object() + notification.mark_read() + return Response(self.get_serializer(notification).data) + + @action(detail=True, methods=["post"], url_path="mark-unread") + def mark_unread(self, request, pk=None): + notification = self.get_object() + notification.mark_unread() + return Response(self.get_serializer(notification).data) + + @action(detail=True, methods=["post"], url_path="archive") + def archive(self, request, pk=None): + notification = self.get_object() + notification.archive() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/core/backend/apps/products/__init__.py b/core/backend/apps/products/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/products/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/products/admin.py b/core/backend/apps/products/admin.py new file mode 100644 index 0000000..a732a9f --- /dev/null +++ b/core/backend/apps/products/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from .models import Product, ProductImage, ProductSellingPoint + + +class ProductImageInline(admin.TabularInline): + model = ProductImage + extra = 0 + + +class ProductSellingPointInline(admin.TabularInline): + model = ProductSellingPoint + extra = 0 + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ("title", "team", "brand", "category", "status", "updated_at") + search_fields = ("title", "brand", "category", "team__name") + list_filter = ("status", "category") + inlines = [ProductImageInline, ProductSellingPointInline] + diff --git a/core/backend/apps/products/apps.py b/core/backend/apps/products/apps.py new file mode 100644 index 0000000..ee8d123 --- /dev/null +++ b/core/backend/apps/products/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.products" + diff --git a/core/backend/apps/products/migrations/0001_initial.py b/core/backend/apps/products/migrations/0001_initial.py new file mode 100644 index 0000000..4d78d6e --- /dev/null +++ b/core/backend/apps/products/migrations/0001_initial.py @@ -0,0 +1,157 @@ +# 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"), + ("assets", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Product", + 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)), + ("title", models.CharField(max_length=255)), + ("brand", models.CharField(blank=True, max_length=128)), + ("category", models.CharField(blank=True, max_length=128)), + ("target_audience", models.CharField(blank=True, max_length=255)), + ("specs", models.JSONField(blank=True, default=dict)), + ("description", models.TextField(blank=True)), + ( + "status", + models.CharField( + choices=[("active", "Active"), ("archived", "Archived")], + default="active", + max_length=24, + ), + ), + ( + "cover_asset", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="covered_products", + to="assets.asset", + ), + ), + ( + "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, + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="accounts.team", + ), + ), + ], + ), + migrations.CreateModel( + name="ProductImage", + 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)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("is_primary", models.BooleanField(default=False)), + ( + "asset", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="product_images", + to="assets.asset", + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="products.product", + ), + ), + ], + options={ + "ordering": ["sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="ProductSellingPoint", + 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)), + ("title", models.CharField(max_length=128)), + ("detail", models.TextField(blank=True)), + ("sort_order", models.PositiveIntegerField(default=0)), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="selling_points", + to="products.product", + ), + ), + ], + options={ + "ordering": ["sort_order", "created_at"], + }, + ), + migrations.AddIndex( + model_name="product", + index=models.Index( + fields=["team", "status"], name="products_pr_team_id_21af15_idx" + ), + ), + migrations.AddIndex( + model_name="product", + index=models.Index( + fields=["team", "category"], name="products_pr_team_id_1f3cfb_idx" + ), + ), + ] diff --git a/core/backend/apps/products/migrations/__init__.py b/core/backend/apps/products/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/products/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/products/models.py b/core/backend/apps/products/models.py new file mode 100644 index 0000000..df6063f --- /dev/null +++ b/core/backend/apps/products/models.py @@ -0,0 +1,54 @@ +from django.db import models + +from apps.common.models import TeamOwnedModel, TimeStampedModel + + +class Product(TeamOwnedModel): + class Status(models.TextChoices): + ACTIVE = "active", "Active" + ARCHIVED = "archived", "Archived" + + title = models.CharField(max_length=255) + brand = models.CharField(max_length=128, blank=True) + category = models.CharField(max_length=128, blank=True) + target_audience = models.CharField(max_length=255, blank=True) + specs = models.JSONField(default=dict, blank=True) + description = models.TextField(blank=True) + status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE) + cover_asset = models.ForeignKey( + "assets.Asset", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="covered_products", + ) + + class Meta: + indexes = [ + models.Index(fields=["team", "status"]), + models.Index(fields=["team", "category"]), + ] + + def __str__(self) -> str: + return self.title + + +class ProductImage(TimeStampedModel): + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="images") + asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="product_images") + sort_order = models.PositiveIntegerField(default=0) + is_primary = models.BooleanField(default=False) + + class Meta: + ordering = ["sort_order", "created_at"] + + +class ProductSellingPoint(TimeStampedModel): + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="selling_points") + title = models.CharField(max_length=128) + detail = models.TextField(blank=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "created_at"] + diff --git a/core/backend/apps/products/serializers.py b/core/backend/apps/products/serializers.py new file mode 100644 index 0000000..c701037 --- /dev/null +++ b/core/backend/apps/products/serializers.py @@ -0,0 +1,65 @@ +from rest_framework import serializers + +from .models import Product, ProductImage, ProductSellingPoint + + +class ProductImageSerializer(serializers.ModelSerializer): + class Meta: + model = ProductImage + fields = ["id", "asset", "sort_order", "is_primary"] + + +class ProductSellingPointSerializer(serializers.ModelSerializer): + class Meta: + model = ProductSellingPoint + fields = ["id", "title", "detail", "sort_order"] + + +class ProductSerializer(serializers.ModelSerializer): + images = ProductImageSerializer(many=True, required=False) + selling_points = ProductSellingPointSerializer(many=True, required=False) + + class Meta: + model = Product + fields = [ + "id", + "title", + "brand", + "category", + "target_audience", + "specs", + "description", + "status", + "cover_asset", + "images", + "selling_points", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def create(self, validated_data): + images = validated_data.pop("images", []) + selling_points = validated_data.pop("selling_points", []) + product = Product.objects.create(**validated_data) + self._sync_children(product, images, selling_points) + return product + + def update(self, instance, validated_data): + images = validated_data.pop("images", None) + selling_points = validated_data.pop("selling_points", None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + self._sync_children(instance, images, selling_points) + return instance + + def _sync_children(self, product, images, selling_points): + if images is not None: + product.images.all().delete() + for item in images: + ProductImage.objects.create(product=product, **item) + if selling_points is not None: + product.selling_points.all().delete() + for item in selling_points: + ProductSellingPoint.objects.create(product=product, **item) diff --git a/core/backend/apps/products/urls.py b/core/backend/apps/products/urls.py new file mode 100644 index 0000000..0838812 --- /dev/null +++ b/core/backend/apps/products/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +from .views import ProductViewSet + +router = DefaultRouter() +router.register("", ProductViewSet, basename="product") + +urlpatterns = router.urls + diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py new file mode 100644 index 0000000..5f73ef1 --- /dev/null +++ b/core/backend/apps/products/views.py @@ -0,0 +1,14 @@ +from rest_framework.viewsets import ModelViewSet + +from apps.common.api import TeamScopedViewSetMixin + +from .models import Product +from .serializers import ProductSerializer + + +class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): + queryset = Product.objects.prefetch_related("images", "selling_points").all() + serializer_class = ProductSerializer + search_fields = ["title", "brand", "category"] + ordering_fields = ["created_at", "updated_at", "title"] + diff --git a/core/backend/apps/projects/__init__.py b/core/backend/apps/projects/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/projects/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/projects/admin.py b/core/backend/apps/projects/admin.py new file mode 100644 index 0000000..69e0457 --- /dev/null +++ b/core/backend/apps/projects/admin.py @@ -0,0 +1,46 @@ +from django.contrib import admin + +from .models import ( + BaseAssetGroup, + BgmTrack, + ExportJob, + Project, + ProjectStage, + ScriptSegment, + ScriptVersion, + StoryboardFrame, + StoryboardVersion, + SubtitleTrack, + Timeline, + TimelineClip, + VideoSegment, + VideoSegmentVersion, +) + + +class ProjectStageInline(admin.TabularInline): + model = ProjectStage + extra = 0 + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + list_display = ("name", "team", "product", "status", "current_stage", "updated_at") + search_fields = ("name", "team__name", "product__title") + list_filter = ("status", "current_stage") + inlines = [ProjectStageInline] + + +admin.site.register(ScriptVersion) +admin.site.register(ScriptSegment) +admin.site.register(BaseAssetGroup) +admin.site.register(StoryboardVersion) +admin.site.register(StoryboardFrame) +admin.site.register(VideoSegment) +admin.site.register(VideoSegmentVersion) +admin.site.register(Timeline) +admin.site.register(TimelineClip) +admin.site.register(SubtitleTrack) +admin.site.register(BgmTrack) +admin.site.register(ExportJob) + diff --git a/core/backend/apps/projects/apps.py b/core/backend/apps/projects/apps.py new file mode 100644 index 0000000..2c884e2 --- /dev/null +++ b/core/backend/apps/projects/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.projects" + diff --git a/core/backend/apps/projects/migrations/0001_initial.py b/core/backend/apps/projects/migrations/0001_initial.py new file mode 100644 index 0000000..ee6784e --- /dev/null +++ b/core/backend/apps/projects/migrations/0001_initial.py @@ -0,0 +1,721 @@ +# 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"), + ("assets", "0001_initial"), + ("products", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Project", + 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)), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("scripting", "Scripting"), + ("asseting", "Asseting"), + ("storyboarding", "Storyboarding"), + ("videoing", "Videoing"), + ("exporting", "Exporting"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="draft", + max_length=32, + ), + ), + ("current_stage", models.CharField(default="script", max_length=32)), + ( + "budget_limit", + models.DecimalField( + blank=True, decimal_places=2, max_digits=12, null=True + ), + ), + ("failure_reason", models.TextField(blank=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "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, + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="projects", + to="products.product", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="accounts.team", + ), + ), + ], + ), + migrations.CreateModel( + name="BaseAssetGroup", + 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)), + ( + "kind", + models.CharField( + choices=[ + ("product", "Product"), + ("person", "Person"), + ("scene", "Scene"), + ], + max_length=32, + ), + ), + ("prompt", models.TextField(blank=True)), + ("version", models.PositiveIntegerField(default=1)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "adopted_asset", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="adopted_base_groups", + to="assets.asset", + ), + ), + ( + "candidate_assets", + models.ManyToManyField( + blank=True, + related_name="candidate_base_groups", + to="assets.asset", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="base_asset_groups", + to="ai.aitask", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="base_asset_groups", + to="projects.project", + ), + ), + ], + ), + migrations.CreateModel( + name="ProjectStage", + 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)), + ( + "stage", + models.CharField( + choices=[ + ("script", "Script"), + ("base_assets", "Base Assets"), + ("storyboard", "Storyboard"), + ("video", "Video"), + ("export", "Export"), + ], + max_length=32, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("not_started", "Not Started"), + ("draft", "Draft"), + ("queued", "Queued"), + ("running", "Running"), + ("succeeded", "Succeeded"), + ("failed", "Failed"), + ("skipped", "Skipped"), + ("needs_review", "Needs Review"), + ], + default="not_started", + max_length=32, + ), + ), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("error_message", models.TextField(blank=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stages", + to="projects.project", + ), + ), + ], + options={ + "ordering": ["created_at"], + }, + ), + migrations.CreateModel( + name="ScriptVersion", + 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)), + ("title", models.CharField(blank=True, max_length=128)), + ("content", models.TextField()), + ("source", models.CharField(default="ai", max_length=32)), + ("is_adopted", models.BooleanField(default=False)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="script_versions", + to="projects.project", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="script_versions", + to="ai.aitask", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="ScriptSegment", + 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)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("duration_seconds", models.PositiveIntegerField(default=15)), + ("narration", models.TextField(blank=True)), + ("visual_prompt", models.TextField(blank=True)), + ("product_points", models.JSONField(blank=True, default=list)), + ( + "script_version", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="segments", + to="projects.scriptversion", + ), + ), + ], + options={ + "ordering": ["sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="StoryboardVersion", + 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)), + ("prompt", models.TextField(blank=True)), + ("is_adopted", models.BooleanField(default=False)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="storyboard_versions", + to="projects.project", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="storyboard_versions", + to="ai.aitask", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="StoryboardFrame", + 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)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("prompt", models.TextField(blank=True)), + ( + "asset", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="storyboard_frames", + to="assets.asset", + ), + ), + ( + "script_segment", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="storyboard_frames", + to="projects.scriptsegment", + ), + ), + ( + "storyboard", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="frames", + to="projects.storyboardversion", + ), + ), + ], + options={ + "ordering": ["sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="Timeline", + 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(blank=True, max_length=255)), + ("aspect_ratio", models.CharField(default="9:16", max_length=16)), + ("resolution", models.CharField(default="1080x1920", max_length=32)), + ("duration_seconds", models.PositiveIntegerField(default=60)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="timeline", + to="projects.project", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="SubtitleTrack", + 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)), + ("content", models.JSONField(blank=True, default=list)), + ("style", models.JSONField(blank=True, default=dict)), + ("enabled", models.BooleanField(default=True)), + ( + "timeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subtitle_tracks", + to="projects.timeline", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="ExportJob", + 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)), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("queued", "Queued"), + ("running", "Running"), + ("succeeded", "Succeeded"), + ("failed", "Failed"), + ], + default="draft", + max_length=32, + ), + ), + ("progress", models.PositiveIntegerField(default=0)), + ("error_message", models.TextField(blank=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "output_asset", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="export_jobs", + to="assets.asset", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="export_jobs", + to="ai.aitask", + ), + ), + ( + "timeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="export_jobs", + to="projects.timeline", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="BgmTrack", + 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)), + ("volume", models.PositiveIntegerField(default=60)), + ("start_ms", models.PositiveIntegerField(default=0)), + ( + "asset", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="bgm_tracks", + to="assets.asset", + ), + ), + ( + "timeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bgm_tracks", + to="projects.timeline", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="TimelineClip", + 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)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("start_ms", models.PositiveIntegerField(default=0)), + ("duration_ms", models.PositiveIntegerField(default=15000)), + ("trim_start_ms", models.PositiveIntegerField(default=0)), + ("trim_end_ms", models.PositiveIntegerField(blank=True, null=True)), + ( + "asset", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="timeline_clips", + to="assets.asset", + ), + ), + ( + "timeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="clips", + to="projects.timeline", + ), + ), + ], + options={ + "ordering": ["sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="VideoSegment", + 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)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("target_duration_seconds", models.PositiveIntegerField(default=15)), + ( + "status", + models.CharField( + choices=[ + ("not_started", "Not Started"), + ("queued", "Queued"), + ("running", "Running"), + ("succeeded", "Succeeded"), + ("failed", "Failed"), + ], + default="not_started", + max_length=32, + ), + ), + ("error_message", models.TextField(blank=True)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="video_segments", + to="projects.project", + ), + ), + ( + "script_segment", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="video_segments", + to="projects.scriptsegment", + ), + ), + ], + options={ + "ordering": ["sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="VideoSegmentVersion", + 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)), + ("prompt", models.TextField(blank=True)), + ("is_adopted", models.BooleanField(default=False)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "asset", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="video_segment_versions", + to="assets.asset", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="video_versions", + to="ai.aitask", + ), + ), + ( + "video_segment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="projects.videosegment", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="videosegment", + name="adopted_version", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="adopted_by_segments", + to="projects.videosegmentversion", + ), + ), + migrations.AddIndex( + model_name="project", + index=models.Index( + fields=["team", "status"], name="projects_pr_team_id_4a0091_idx" + ), + ), + migrations.AddIndex( + model_name="project", + index=models.Index( + fields=["team", "current_stage"], name="projects_pr_team_id_a3c9ff_idx" + ), + ), + migrations.AddIndex( + model_name="baseassetgroup", + index=models.Index( + fields=["project", "kind"], name="projects_ba_project_8fb70a_idx" + ), + ), + migrations.AlterUniqueTogether( + name="projectstage", + unique_together={("project", "stage")}, + ), + migrations.AlterUniqueTogether( + name="videosegment", + unique_together={("project", "sort_order")}, + ), + ] diff --git a/core/backend/apps/projects/migrations/__init__.py b/core/backend/apps/projects/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/projects/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/projects/models.py b/core/backend/apps/projects/models.py new file mode 100644 index 0000000..d252cde --- /dev/null +++ b/core/backend/apps/projects/models.py @@ -0,0 +1,237 @@ +from django.db import models + +from apps.common.models import TeamOwnedModel, TimeStampedModel + + +class Project(TeamOwnedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SCRIPTING = "scripting", "Scripting" + ASSETING = "asseting", "Asseting" + STORYBOARDING = "storyboarding", "Storyboarding" + VIDEOING = "videoing", "Videoing" + EXPORTING = "exporting", "Exporting" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + + name = models.CharField(max_length=255) + product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects") + status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT) + current_stage = models.CharField(max_length=32, default="script") + budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + failure_reason = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["team", "status"]), + models.Index(fields=["team", "current_stage"]), + ] + + def __str__(self) -> str: + return self.name + + +class ProjectStage(TimeStampedModel): + class Stage(models.TextChoices): + SCRIPT = "script", "Script" + BASE_ASSETS = "base_assets", "Base Assets" + STORYBOARD = "storyboard", "Storyboard" + VIDEO = "video", "Video" + EXPORT = "export", "Export" + + class Status(models.TextChoices): + NOT_STARTED = "not_started", "Not Started" + DRAFT = "draft", "Draft" + QUEUED = "queued", "Queued" + RUNNING = "running", "Running" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + SKIPPED = "skipped", "Skipped" + NEEDS_REVIEW = "needs_review", "Needs Review" + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stages") + stage = models.CharField(max_length=32, choices=Stage.choices) + status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + error_message = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + unique_together = [("project", "stage")] + ordering = ["created_at"] + + +class ScriptVersion(TimeStampedModel): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions") + task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions") + title = models.CharField(max_length=128, blank=True) + content = models.TextField() + source = models.CharField(max_length=32, default="ai") + is_adopted = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + + +class ScriptSegment(TimeStampedModel): + script_version = models.ForeignKey(ScriptVersion, on_delete=models.CASCADE, related_name="segments") + sort_order = models.PositiveIntegerField(default=0) + duration_seconds = models.PositiveIntegerField(default=15) + narration = models.TextField(blank=True) + visual_prompt = models.TextField(blank=True) + product_points = models.JSONField(default=list, blank=True) + + class Meta: + ordering = ["sort_order", "created_at"] + + +class BaseAssetGroup(TimeStampedModel): + class Kind(models.TextChoices): + PRODUCT = "product", "Product" + PERSON = "person", "Person" + SCENE = "scene", "Scene" + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="base_asset_groups") + kind = models.CharField(max_length=32, choices=Kind.choices) + task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="base_asset_groups") + prompt = models.TextField(blank=True) + adopted_asset = models.ForeignKey( + "assets.Asset", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="adopted_base_groups", + ) + candidate_assets = models.ManyToManyField("assets.Asset", blank=True, related_name="candidate_base_groups") + version = models.PositiveIntegerField(default=1) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + indexes = [models.Index(fields=["project", "kind"])] + + +class StoryboardVersion(TimeStampedModel): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_versions") + task = models.ForeignKey( + "ai.AITask", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="storyboard_versions", + ) + prompt = models.TextField(blank=True) + is_adopted = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + + +class StoryboardFrame(TimeStampedModel): + storyboard = models.ForeignKey(StoryboardVersion, on_delete=models.CASCADE, related_name="frames") + script_segment = models.ForeignKey( + ScriptSegment, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="storyboard_frames", + ) + asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_frames") + sort_order = models.PositiveIntegerField(default=0) + prompt = models.TextField(blank=True) + + class Meta: + ordering = ["sort_order", "created_at"] + + +class VideoSegment(TimeStampedModel): + class Status(models.TextChoices): + NOT_STARTED = "not_started", "Not Started" + QUEUED = "queued", "Queued" + RUNNING = "running", "Running" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="video_segments") + script_segment = models.ForeignKey( + ScriptSegment, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="video_segments", + ) + sort_order = models.PositiveIntegerField(default=0) + target_duration_seconds = models.PositiveIntegerField(default=15) + status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED) + adopted_version = models.ForeignKey( + "projects.VideoSegmentVersion", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="adopted_by_segments", + ) + error_message = models.TextField(blank=True) + + class Meta: + unique_together = [("project", "sort_order")] + ordering = ["sort_order", "created_at"] + + +class VideoSegmentVersion(TimeStampedModel): + video_segment = models.ForeignKey(VideoSegment, on_delete=models.CASCADE, related_name="versions") + task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_versions") + asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="video_segment_versions") + prompt = models.TextField(blank=True) + is_adopted = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + + +class Timeline(TimeStampedModel): + project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="timeline") + name = models.CharField(max_length=255, blank=True) + aspect_ratio = models.CharField(max_length=16, default="9:16") + resolution = models.CharField(max_length=32, default="1080x1920") + duration_seconds = models.PositiveIntegerField(default=60) + metadata = models.JSONField(default=dict, blank=True) + + +class TimelineClip(TimeStampedModel): + timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="clips") + asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="timeline_clips") + sort_order = models.PositiveIntegerField(default=0) + start_ms = models.PositiveIntegerField(default=0) + duration_ms = models.PositiveIntegerField(default=15000) + trim_start_ms = models.PositiveIntegerField(default=0) + trim_end_ms = models.PositiveIntegerField(null=True, blank=True) + + class Meta: + ordering = ["sort_order", "created_at"] + + +class SubtitleTrack(TimeStampedModel): + timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="subtitle_tracks") + content = models.JSONField(default=list, blank=True) + style = models.JSONField(default=dict, blank=True) + enabled = models.BooleanField(default=True) + + +class BgmTrack(TimeStampedModel): + timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="bgm_tracks") + asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="bgm_tracks") + volume = models.PositiveIntegerField(default=60) + start_ms = models.PositiveIntegerField(default=0) + + +class ExportJob(TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + QUEUED = "queued", "Queued" + RUNNING = "running", "Running" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + + timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="export_jobs") + status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT) + task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs") + output_asset = models.ForeignKey("assets.Asset", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs") + progress = models.PositiveIntegerField(default=0) + error_message = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py new file mode 100644 index 0000000..d6b06ca --- /dev/null +++ b/core/backend/apps/projects/serializers.py @@ -0,0 +1,140 @@ +from rest_framework import serializers + +from .models import ( + BaseAssetGroup, + ExportJob, + Project, + ProjectStage, + ScriptSegment, + ScriptVersion, + StoryboardFrame, + StoryboardVersion, + Timeline, + TimelineClip, + VideoSegment, + VideoSegmentVersion, +) + + +class ProjectStageSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectStage + fields = ["id", "stage", "status", "started_at", "completed_at", "error_message", "metadata"] + read_only_fields = fields + + +class VideoSegmentSerializer(serializers.ModelSerializer): + class Meta: + model = VideoSegment + fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"] + read_only_fields = fields + + +class BaseAssetGroupSerializer(serializers.ModelSerializer): + candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + + class Meta: + model = BaseAssetGroup + fields = ["id", "kind", "prompt", "adopted_asset", "candidate_assets", "version", "metadata", "created_at"] + read_only_fields = fields + + +class StoryboardFrameSerializer(serializers.ModelSerializer): + class Meta: + model = StoryboardFrame + fields = ["id", "script_segment", "asset", "sort_order", "prompt"] + read_only_fields = fields + + +class StoryboardVersionSerializer(serializers.ModelSerializer): + frames = StoryboardFrameSerializer(many=True, read_only=True) + + class Meta: + model = StoryboardVersion + fields = ["id", "prompt", "is_adopted", "frames", "created_at", "updated_at"] + read_only_fields = fields + + +class VideoSegmentVersionSerializer(serializers.ModelSerializer): + class Meta: + model = VideoSegmentVersion + fields = ["id", "video_segment", "asset", "prompt", "is_adopted", "metadata", "created_at"] + read_only_fields = fields + + +class TimelineClipSerializer(serializers.ModelSerializer): + class Meta: + model = TimelineClip + fields = ["id", "asset", "sort_order", "start_ms", "duration_ms", "trim_start_ms", "trim_end_ms"] + read_only_fields = ["id"] + + +class TimelineExportJobSerializer(serializers.ModelSerializer): + class Meta: + model = ExportJob + fields = ["id", "status", "output_asset", "progress", "error_message", "created_at", "updated_at"] + read_only_fields = fields + + +class TimelineSerializer(serializers.ModelSerializer): + clips = TimelineClipSerializer(many=True, read_only=True) + export_jobs = TimelineExportJobSerializer(many=True, read_only=True) + + class Meta: + model = Timeline + fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "clips", "export_jobs"] + read_only_fields = ["id", "clips", "export_jobs"] + + +class ExportJobSerializer(serializers.ModelSerializer): + class Meta: + model = ExportJob + fields = ["id", "status", "output_asset", "progress", "error_message", "metadata", "created_at", "updated_at"] + read_only_fields = fields + + +class ScriptSegmentSerializer(serializers.ModelSerializer): + class Meta: + model = ScriptSegment + fields = ["id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points"] + read_only_fields = fields + + +class ScriptVersionSerializer(serializers.ModelSerializer): + segments = ScriptSegmentSerializer(many=True, read_only=True) + + class Meta: + model = ScriptVersion + fields = ["id", "title", "content", "source", "is_adopted", "segments", "created_at", "updated_at"] + read_only_fields = fields + + +class ProjectSerializer(serializers.ModelSerializer): + stages = ProjectStageSerializer(many=True, read_only=True) + video_segments = VideoSegmentSerializer(many=True, read_only=True) + script_versions = ScriptVersionSerializer(many=True, read_only=True) + base_asset_groups = BaseAssetGroupSerializer(many=True, read_only=True) + storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) + timeline = TimelineSerializer(read_only=True) + + class Meta: + model = Project + fields = [ + "id", + "name", + "product", + "status", + "current_stage", + "budget_limit", + "failure_reason", + "metadata", + "stages", + "script_versions", + "base_asset_groups", + "storyboard_versions", + "video_segments", + "timeline", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"] diff --git a/core/backend/apps/projects/services/__init__.py b/core/backend/apps/projects/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/backend/apps/projects/services/__init__.py @@ -0,0 +1 @@ + diff --git a/core/backend/apps/projects/services/export.py b/core/backend/apps/projects/services/export.py new file mode 100644 index 0000000..1dd7db5 --- /dev/null +++ b/core/backend/apps/projects/services/export.py @@ -0,0 +1,105 @@ +from pathlib import Path +import subprocess +import tempfile + +import requests +from django.db import transaction + +from apps.assets.models import Asset, AssetFile +from apps.assets.storage import TosStorage +from apps.projects.models import ExportJob + + +def _download_asset_primary_file(asset, target_path: Path) -> None: + primary = asset.files.filter(is_primary=True).first() or asset.files.first() + if primary is None: + raise ValueError(f"asset {asset.id} has no file") + url = TosStorage().presigned_get_url(object_key=primary.object_key, expires_in=3600) + response = requests.get(url, timeout=180) + response.raise_for_status() + target_path.write_bytes(response.content) + + +def run_export_job(export_job_id: str) -> ExportJob: + export_job = ExportJob.objects.select_related("timeline", "timeline__project").get(id=export_job_id) + timeline = export_job.timeline + project = timeline.project + clips = list(timeline.clips.select_related("asset").order_by("sort_order")) + if not clips: + raise ValueError("timeline has no clips") + + export_job.status = ExportJob.Status.RUNNING + export_job.progress = 10 + export_job.save(update_fields=["status", "progress", "updated_at"]) + + with tempfile.TemporaryDirectory(prefix="airshelf-export-") as tmp_dir: + tmp = Path(tmp_dir) + concat_file = tmp / "concat.txt" + downloaded_files: list[Path] = [] + for index, clip in enumerate(clips): + clip_path = tmp / f"clip-{index}.mp4" + _download_asset_primary_file(clip.asset, clip_path) + downloaded_files.append(clip_path) + concat_file.write_text( + "\n".join(f"file '{path.as_posix()}'" for path in downloaded_files), + encoding="utf-8", + ) + output_path = tmp / "output.mp4" + command = [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_file), + "-vf", + "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2", + "-r", + "30", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-movflags", + "+faststart", + str(output_path), + ] + subprocess.run(command, check=True, capture_output=True) + export_job.progress = 85 + export_job.save(update_fields=["progress", "updated_at"]) + + with output_path.open("rb") as fileobj: + asset_id = export_job.id + object_key = f"teams/{project.team_id}/projects/{project.id}/exports/{asset_id}.mp4" + stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type="video/mp4") + + with transaction.atomic(): + asset = Asset.objects.create( + team=project.team, + created_by=project.created_by, + name=f"{project.name}-final.mp4", + asset_type=Asset.Type.VIDEO, + source=Asset.Source.EXPORTED, + category=Asset.Category.FINAL_VIDEO, + ) + 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, + ) + export_job.output_asset = asset + export_job.status = ExportJob.Status.SUCCEEDED + export_job.progress = 100 + export_job.error_message = "" + export_job.save(update_fields=["output_asset", "status", "progress", "error_message", "updated_at"]) + project.status = project.Status.COMPLETED + project.save(update_fields=["status", "updated_at"]) + return export_job + diff --git a/core/backend/apps/projects/services/pipeline.py b/core/backend/apps/projects/services/pipeline.py new file mode 100644 index 0000000..6224f4f --- /dev/null +++ b/core/backend/apps/projects/services/pipeline.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass + +from apps.projects.models import ProjectStage + + +STAGE_ORDER = [ + ProjectStage.Stage.SCRIPT, + ProjectStage.Stage.BASE_ASSETS, + ProjectStage.Stage.STORYBOARD, + ProjectStage.Stage.VIDEO, + ProjectStage.Stage.EXPORT, +] + + +@dataclass(frozen=True) +class StageTransition: + current: str + target: str + allowed: bool + reason: str = "" + + +def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard: bool = True) -> StageTransition: + if target_stage not in STAGE_ORDER: + return StageTransition(current_stage, target_stage, False, "unknown target stage") + + current_index = STAGE_ORDER.index(current_stage) if current_stage in STAGE_ORDER else -1 + target_index = STAGE_ORDER.index(target_stage) + + if target_index <= current_index + 1: + return StageTransition(current_stage, target_stage, True) + + if allow_skip_storyboard and current_stage == ProjectStage.Stage.BASE_ASSETS and target_stage == ProjectStage.Stage.VIDEO: + return StageTransition(current_stage, target_stage, True) + + return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied") + diff --git a/core/backend/apps/projects/tasks.py b/core/backend/apps/projects/tasks.py new file mode 100644 index 0000000..ac05202 --- /dev/null +++ b/core/backend/apps/projects/tasks.py @@ -0,0 +1,44 @@ +from airshelf.celery import app + +from apps.ai.models import AITask +from apps.ai.services import poll_video_segment +from apps.projects.models import ExportJob +from apps.projects.models import VideoSegment +from apps.projects.services.export import run_export_job + + +@app.task(bind=True, max_retries=120) +def poll_video_segment_task(self, video_segment_id: str) -> str: + segment = VideoSegment.objects.select_related("project", "project__created_by").get(id=video_segment_id) + ai_task = ( + segment.project.ai_tasks.filter( + task_type=AITask.Type.VIDEO_SEGMENT, + request_payload__video_segment_id=str(segment.id), + status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING], + ) + .select_related("created_by") + .order_by("-created_at") + .first() + ) + if ai_task is None: + return video_segment_id + + user = ai_task.created_by or segment.project.created_by + version = poll_video_segment(video_segment=segment, user=user) + if version is None and segment.status in [VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED]: + raise self.retry(countdown=30) + return video_segment_id + + +@app.task(bind=True, max_retries=2) +def run_export_job_task(self, export_job_id: str) -> str: + try: + run_export_job(export_job_id) + except Exception as exc: + export_job = ExportJob.objects.filter(id=export_job_id).first() + if export_job: + export_job.status = ExportJob.Status.FAILED + export_job.error_message = str(exc) + export_job.save(update_fields=["status", "error_message", "updated_at"]) + raise + return export_job_id diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py new file mode 100644 index 0000000..c9b8a20 --- /dev/null +++ b/core/backend/apps/projects/tests.py @@ -0,0 +1,102 @@ +from django.test import TestCase +from unittest.mock import patch + +from rest_framework.test import APIClient + +from apps.accounts.models import Team, TeamMember, User +from apps.ai.models import ModelConfig, ModelProvider +from apps.billing.models import CreditAccount, CreditLedger +from apps.products.models import Product +from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment + + +class ProjectApiTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="owner", password="pass") + self.team = Team.objects.create(name="E2E Team", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance="100.0000") + self.product = Product.objects.create(team=self.team, created_by=self.user, title="Test Product") + self.provider = ModelProvider.objects.create( + name="volcengine", + display_name="Volcano", + base_url="https://ark.cn-beijing.volces.com/api/v3", + ) + self.model = ModelConfig.objects.create( + provider=self.provider, + name="doubao-seed-2-0-pro-260215", + display_name="Doubao", + capability=ModelConfig.Capability.TEXT, + endpoint="chat/completions", + unit_price="2.0000", + ) + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_create_project_initializes_pipeline(self): + response = self.client.post( + "/api/projects/", + {"name": "Launch Video", "product": str(self.product.id)}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(id=response.data["id"]) + self.assertEqual(project.team, self.team) + self.assertEqual(project.created_by, self.user) + self.assertEqual(project.stages.count(), 5) + self.assertEqual(project.video_segments.count(), 4) + self.assertEqual( + list(project.stages.values_list("stage", flat=True)), + [ + ProjectStage.Stage.SCRIPT, + ProjectStage.Stage.BASE_ASSETS, + ProjectStage.Stage.STORYBOARD, + ProjectStage.Stage.VIDEO, + ProjectStage.Stage.EXPORT, + ], + ) + self.assertEqual( + list(project.video_segments.values_list("target_duration_seconds", flat=True)), + [15, 15, 15, 15], + ) + self.assertEqual( + list(project.video_segments.values_list("sort_order", flat=True)), + [0, 1, 2, 3], + ) + self.assertTrue(VideoSegment.objects.filter(project=project).exists()) + + @patch("apps.ai.services.VolcanoArkProvider") + def test_generate_script_creates_script_segments_and_charges_credit(self, provider_cls): + provider = provider_cls.return_value + provider.chat_completion.return_value = { + "choices": [ + { + "message": { + "content": "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化", + } + } + ] + } + provider.extract_text.return_value = "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化" + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="Launch Video") + for stage in [ + ProjectStage.Stage.SCRIPT, + ProjectStage.Stage.BASE_ASSETS, + ProjectStage.Stage.STORYBOARD, + ProjectStage.Stage.VIDEO, + ProjectStage.Stage.EXPORT, + ]: + ProjectStage.objects.create(project=project, stage=stage) + + response = self.client.post( + f"/api/projects/{project.id}/generate-script/", + {"prompt": "突出高转化"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + script = ScriptVersion.objects.get(project=project) + self.assertEqual(script.segments.count(), 4) + self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) + diff --git a/core/backend/apps/projects/urls.py b/core/backend/apps/projects/urls.py new file mode 100644 index 0000000..f529e3e --- /dev/null +++ b/core/backend/apps/projects/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +from .views import ProjectViewSet + +router = DefaultRouter() +router.register("", ProjectViewSet, basename="project") + +urlpatterns = router.urls + diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py new file mode 100644 index 0000000..7e775e8 --- /dev/null +++ b/core/backend/apps/projects/views.py @@ -0,0 +1,198 @@ +from django.db import transaction +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.ai.services import ( + create_export_job, + generate_base_asset, + generate_project_script, + generate_storyboard, + poll_video_segment, + submit_video_segment, +) +from apps.common.api import TeamScopedViewSetMixin + +from .models import BaseAssetGroup, Project, ProjectStage, ScriptVersion, Timeline, TimelineClip, VideoSegment +from .serializers import ( + BaseAssetGroupSerializer, + ExportJobSerializer, + ProjectSerializer, + ScriptVersionSerializer, + StoryboardVersionSerializer, + VideoSegmentVersionSerializer, +) +from .services.pipeline import STAGE_ORDER +from .tasks import poll_video_segment_task, run_export_job_task + + +def promote_base_asset_stage_if_ready(project: Project) -> bool: + adopted_kind_count = ( + project.base_asset_groups.filter(adopted_asset__isnull=False).values("kind").distinct().count() + ) + if adopted_kind_count < 3: + return False + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS) + stage.status = ProjectStage.Status.SUCCEEDED + stage.save(update_fields=["status", "updated_at"]) + project.current_stage = ProjectStage.Stage.STORYBOARD + project.status = Project.Status.STORYBOARDING + project.save(update_fields=["current_stage", "status", "updated_at"]) + return True + + +class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): + queryset = Project.objects.select_related("product", "timeline").prefetch_related( + "stages", + "video_segments", + "script_versions", + "script_versions__segments", + "base_asset_groups", + "base_asset_groups__candidate_assets", + "storyboard_versions", + "storyboard_versions__frames", + "timeline__clips", + ).all() + serializer_class = ProjectSerializer + search_fields = ["name", "product__title"] + ordering_fields = ["created_at", "updated_at", "name"] + + @transaction.atomic + def perform_create(self, serializer): + project = serializer.save(team=self.get_team(), created_by=self.request.user) + for stage in STAGE_ORDER: + ProjectStage.objects.create(project=project, stage=stage) + for index in range(4): + VideoSegment.objects.create(project=project, sort_order=index, target_duration_seconds=15) + + @action(detail=True, methods=["post"], url_path="generate-script") + def generate_script(self, request, pk=None): + project = self.get_object() + script = generate_project_script( + project=project, + user=request.user, + user_prompt=request.data.get("prompt", ""), + selling_point_ids=request.data.get("selling_point_ids") or [], + ) + return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"], url_path="adopt-script") + @transaction.atomic + def adopt_script(self, request, pk=None): + project = self.get_object() + script_id = request.data.get("script_version_id") + script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id) + ScriptVersion.objects.filter(project=project).update(is_adopted=False) + script.is_adopted = True + script.save(update_fields=["is_adopted", "updated_at"]) + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) + stage.status = ProjectStage.Status.SUCCEEDED + stage.save(update_fields=["status", "updated_at"]) + project.current_stage = ProjectStage.Stage.BASE_ASSETS + project.status = Project.Status.ASSETING + project.save(update_fields=["current_stage", "status", "updated_at"]) + return Response(ScriptVersionSerializer(script).data) + + @action(detail=True, methods=["post"], url_path="generate-base-asset") + def generate_base_asset_action(self, request, pk=None): + project = self.get_object() + kind = request.data.get("kind") + if kind not in BaseAssetGroup.Kind.values: + return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST) + group = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", "")) + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS) + stage.status = ProjectStage.Status.NEEDS_REVIEW + stage.save(update_fields=["status", "updated_at"]) + promote_base_asset_stage_if_ready(project) + return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"], url_path="adopt-base-asset") + @transaction.atomic + def adopt_base_asset(self, request, pk=None): + project = self.get_object() + group = BaseAssetGroup.objects.select_for_update().get(project=project, id=request.data.get("group_id")) + asset_id = request.data.get("asset_id") + if not group.candidate_assets.filter(id=asset_id).exists(): + return Response({"detail": "asset is not a candidate"}, status=status.HTTP_400_BAD_REQUEST) + group.adopted_asset_id = asset_id + group.save(update_fields=["adopted_asset", "updated_at"]) + promote_base_asset_stage_if_ready(project) + return Response(BaseAssetGroupSerializer(group).data) + + @action(detail=True, methods=["post"], url_path="generate-storyboard") + def generate_storyboard_action(self, request, pk=None): + project = self.get_object() + storyboard = generate_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", "")) + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) + stage.status = ProjectStage.Status.SUCCEEDED + stage.save(update_fields=["status", "updated_at"]) + project.current_stage = ProjectStage.Stage.VIDEO + project.status = Project.Status.VIDEOING + project.save(update_fields=["current_stage", "status", "updated_at"]) + return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"], url_path="skip-storyboard") + @transaction.atomic + def skip_storyboard(self, request, pk=None): + project = self.get_object() + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) + stage.status = ProjectStage.Status.SKIPPED + stage.save(update_fields=["status", "updated_at"]) + project.current_stage = ProjectStage.Stage.VIDEO + project.status = Project.Status.VIDEOING + project.save(update_fields=["current_stage", "status", "updated_at"]) + return Response(ProjectSerializer(project).data) + + @action(detail=True, methods=["post"], url_path="submit-video-segment") + def submit_video_segment_action(self, request, pk=None): + project = self.get_object() + segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id")) + submit_video_segment(video_segment=segment, user=request.user, prompt=request.data.get("prompt", "")) + poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30) + return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED) + + @action(detail=True, methods=["post"], url_path="poll-video-segment") + def poll_video_segment_action(self, request, pk=None): + project = self.get_object() + segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id")) + version = poll_video_segment(video_segment=segment, user=request.user) + if version is None: + return Response({"status": segment.status}, status=status.HTTP_202_ACCEPTED) + return Response(VideoSegmentVersionSerializer(version).data) + + @action(detail=True, methods=["post"], url_path="submit-export") + @transaction.atomic + def submit_export(self, request, pk=None): + project = self.get_object() + missing_segments = project.video_segments.filter(adopted_version__isnull=True).count() + if missing_segments: + return Response( + {"detail": f"{missing_segments} video segments are not ready for export"}, + status=status.HTTP_400_BAD_REQUEST, + ) + timeline, _ = Timeline.objects.get_or_create( + project=project, + defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}, + ) + if not timeline.clips.exists(): + start_ms = 0 + for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"): + if segment.adopted_version_id: + TimelineClip.objects.create( + timeline=timeline, + asset=segment.adopted_version.asset, + sort_order=segment.sort_order, + start_ms=start_ms, + duration_ms=segment.target_duration_seconds * 1000, + ) + start_ms += segment.target_duration_seconds * 1000 + export_job = create_export_job(timeline=timeline, user=request.user) + run_export_job_task.delay(str(export_job.id)) + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT) + stage.status = ProjectStage.Status.QUEUED + stage.save(update_fields=["status", "updated_at"]) + project.current_stage = ProjectStage.Stage.EXPORT + project.status = Project.Status.EXPORTING + project.save(update_fields=["current_stage", "status", "updated_at"]) + return Response(ExportJobSerializer(export_job).data, status=status.HTTP_202_ACCEPTED) diff --git a/core/backend/manage.py b/core/backend/manage.py new file mode 100644 index 0000000..d059090 --- /dev/null +++ b/core/backend/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() + diff --git a/core/backend/requirements.txt b/core/backend/requirements.txt new file mode 100644 index 0000000..378ff1f --- /dev/null +++ b/core/backend/requirements.txt @@ -0,0 +1,11 @@ +Django>=5.0,<5.2 +djangorestframework>=3.15,<3.16 +django-cors-headers>=4.3,<5.0 +celery>=5.3,<6.0 +redis>=5.0,<6.0 +PyMySQL>=1.1,<2.0 +python-dotenv>=1.0,<2.0 +boto3>=1.34,<2.0 +requests>=2.31,<3.0 +gunicorn>=21.2,<23.0 + diff --git a/core/frontend/EXACT_REACT_CONVERSION.md b/core/frontend/EXACT_REACT_CONVERSION.md new file mode 100644 index 0000000..e04a80f --- /dev/null +++ b/core/frontend/EXACT_REACT_CONVERSION.md @@ -0,0 +1,42 @@ +# Exact Design To React Conversion + +> 日期:2026-05-29 +> 目标:先把 `电商AI平台` / `public/exact` 设计稿完整纳入正式 React 路由,确保视觉还原优先于业务接口继续扩展。 + +## 当前实现 + +- `public/exact/*.html`:保留为设计对照基线和截图 diff 来源。 +- `src/routes/exact-html.ts`:由脚本生成,内嵌 21 个 exact 页面文档。 +- `src/routes/exact-document.tsx`:React 承载层,负责加载 exact 文档、隔离页面样式、接管页面间导航和登录注册提交。 +- `src/routes/exact-pages/*.tsx`:每个页面对应一个正式 TSX 包装文件。 +- `scripts/generate-exact-html.mjs`:从 `public/exact` 重新生成 React exact 文档与页面包装。 + +## 已覆盖页面 + +登录、注册、工作台、商品库、商品详情、商品创建入口、项目列表、新建项目、Pipeline、资产库、账户、团队、消息、图片工具、图片优化、模特图、模特图方案 A/B、平台套图、设置。 + +## 验收结果 + +验证命令: + +```bash +npm run build +``` + +Playwright 真实浏览器截图对照输出: + +```text +output/playwright/exact-react-final-20260529103829/ +``` + +关键结果: + +- 登录 / 注册:0 像素差。 +- Dashboard / 商品库 / 项目列表 / Pipeline / 资产库 / 账户 / 团队 / 消息 / AI 工具页:与 exact 基线基本一致,差异为 0 到极低比例。 +- `product-create-upload.html` 是原型里的跳转 stub,实际落到商品库 drawer,因此差异高于其他页面,但属于原型跳转页本身的动态行为差异。 + +## 后续规则 + +1. 修改页面前先改 `public/exact` 或确认设计基线,再运行生成脚本。 +2. 业务 API 接入时,不直接破坏 exact 视觉层;先在承载层或组件层做数据替换。 +3. 生视频真实测试仍放最后,不在页面还原阶段触发。 diff --git a/core/frontend/index.html b/core/frontend/index.html new file mode 100644 index 0000000..7ed3500 --- /dev/null +++ b/core/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + AirShelf + + +
+ + + diff --git a/core/frontend/package-lock.json b/core/frontend/package-lock.json new file mode 100644 index 0000000..fc20cd4 --- /dev/null +++ b/core/frontend/package-lock.json @@ -0,0 +1,1721 @@ +{ + "name": "airshelf-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "airshelf-frontend", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "^5.1.1", + "lucide-react": "^0.561.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "typescript": "^5.9.3", + "vite": "^7.2.7" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.561.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", + "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/core/frontend/package.json b/core/frontend/package.json new file mode 100644 index 0000000..238ba9c --- /dev/null +++ b/core/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "airshelf-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.1.1", + "vite": "^7.2.7", + "typescript": "^5.9.3", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "lucide-react": "^0.561.0" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3" + } +} diff --git a/core/frontend/public/assets/logo-dark.png b/core/frontend/public/assets/logo-dark.png new file mode 100644 index 0000000..42f1fdb Binary files /dev/null and b/core/frontend/public/assets/logo-dark.png differ diff --git a/core/frontend/public/assets/logo.png b/core/frontend/public/assets/logo.png new file mode 100644 index 0000000..94121b1 Binary files /dev/null and b/core/frontend/public/assets/logo.png differ diff --git a/core/frontend/public/exact/.dockerignore b/core/frontend/public/exact/.dockerignore new file mode 100644 index 0000000..bc81268 --- /dev/null +++ b/core/frontend/public/exact/.dockerignore @@ -0,0 +1,7 @@ +Dockerfile +.dockerignore +.git +.gitignore +.DS_Store +*.md +node_modules diff --git a/core/frontend/public/exact/Dockerfile b/core/frontend/public/exact/Dockerfile new file mode 100644 index 0000000..be764df --- /dev/null +++ b/core/frontend/public/exact/Dockerfile @@ -0,0 +1,10 @@ +# ---- Runtime Stage (no build — pure static HTML/CSS/JS) ---- +FROM docker.m.daocloud.io/nginx:alpine + +RUN sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY . /usr/share/nginx/html/ + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/core/frontend/public/exact/README.md b/core/frontend/public/exact/README.md new file mode 100644 index 0000000..054a7b8 --- /dev/null +++ b/core/frontend/public/exact/README.md @@ -0,0 +1,53 @@ +# 电商AI平台 · Airshelf + +AI 短视频带货生成平台 · 静态设计稿(V2.1 Restraint)。 + +## 快速浏览 + +直接用浏览器打开任意 HTML,或本地起服务: + +```bash +npx http-server . -p 8080 +# 然后访问 http://localhost:8080/ +``` + +入口页:[index.html](index.html)(工作台) + +## 页面索引 + +| 页面 | 文件 | 说明 | +| --- | --- | --- | +| 工作台 | [index.html](index.html) | 进入后首屏 · 4 块 KPI + 最近项目 + 快捷入口 | +| 视频项目 | [projects.html](projects.html) | 项目列表 · 表格 + 状态 + 进度 | +| 新建项目 | [projects-new.html](projects-new.html) | 4 步向导 · 含实时预估面板 + 推荐气泡 | +| 商品库 | [products.html](products.html) | SKU 列表 | +| 创建商品 | [product-create.html](product-create.html) | 4 步向导 + 子流程(挑模特 → 生上身) | +| 流水线 | [pipeline.html](pipeline.html) | 5 阶段:脚本 → 资产 → 故事板 → 片段 → 拼接 | +| 资产库 | [library.html](library.html) | 人物 / 场景 / 视频片段管理 | +| 账户 | [account.html](account.html) | 余额 / 充值 / 消费明细 | +| 设计系统 | [design-system.html](design-system.html) | 完整 V2.1 token + 组件 + 状态 + Modal/Toast | + +## 设计规范 + +详见 [DESIGN_SPEC_V2.md](DESIGN_SPEC_V2.md) · 当前 V2.1。 + +**核心视觉特征:** +- 配色:冷灰底 `#f9f9f9` + 单橙 accent `#FA5D19`(Firecrawl-aligned) +- 圆角:统一 8 px / pill 999 px / 微元素 4 px +- Icon:SVG line · stroke 1.5 · linecap round · `currentColor` 继承 +- 签名元素:容器装订线(左右 1 px 边)+ 四角 22×21 px SVG 准星(圆弧内凹) +- 字体:Inter Tight + Inter + Alibaba PuHuiTi +- 主 CTA:橙底 + 4 层橙阴影(全场唯一允许阴影的组件) + +## 目录结构 + +``` +电商AI平台/ +├── README.md ← 本文档 +├── DESIGN_SPEC_V2.md ← 设计规范 source of truth +├── design-system.html ← 可交互组件参考(token + 状态 demo) +├── assets/ +│ ├── restraint.css ← V2.1 共享样式 · 无 V1 legacy alias +│ └── shell.js ← sidebar/topbar/Toast/Modal 渲染器 +└── *.html ← 8 个页面 +``` diff --git a/core/frontend/public/exact/_ARCHIVE.md b/core/frontend/public/exact/_ARCHIVE.md new file mode 100644 index 0000000..c084740 --- /dev/null +++ b/core/frontend/public/exact/_ARCHIVE.md @@ -0,0 +1,54 @@ +# 已归档/保留的 HTML 文件说明 + +> 2026-05-28 · 主流程收口记录 + +下列早期版本或独立实验页**不在主流程上**,已归档到 +[`_archive/deprecated-pages-20260528/`](_archive/deprecated-pages-20260528/)。 + +根目录半成品 Next.js scaffold 已归档到 +[`../_archive/root-next-20260528/`](../_archive/root-next-20260528/)。 + +保留本文件用于说明历史路径,避免后续误改旧页面。 + +## ✅ 已归档(无主流程入口) + +| 文件 | 原用途 | 现状 | +|------|--------|------| +| `_archive/deprecated-pages-20260528/product-create.legacy.html` | 旧版「新建商品」全屏页(3883 行 drawer) | 已被 `product-create.html` stub + drawer 模式替代 | +| `_archive/deprecated-pages-20260528/product-create-v2.html` | 备选「新建商品」实验版 | 仅供参考 | +| `_archive/deprecated-pages-20260528/studio.html` | 早期工作室页占位 | 已被 `pipeline.html` 替代 | +| `_archive/deprecated-pages-20260528/studio-v2.html` | 早期工作室 V2 实验 | 已被 `pipeline.html` 替代 | +| `_archive/deprecated-pages-20260528/product-studio.html` | 商品 + 工作室合并实验 | 主流程已拆为 `product-detail.html` + `pipeline.html` | +| `../_archive/root-next-20260528/` | 根目录 Next.js 半成品 | 静态 HTML 才是当前 source of truth | + +## ⚠️ 仍保留在主流程/辅助流程 + +| 文件 | 决定 | +|------|------| +| `product-create.html` | **保留** · stub 重定向,处理外部书签/直接访问 → 弹 drawer | +| `product-create-upload.html` | **保留** · 商品图上传专用辅助页,通过 drawer 内部调用 | +| `model-photo.html` / `platform-cover.html` | **保留** · 「AI 生成素材」二级页(从商品库 gen-choice 跳入) | +| `asset-factory.html` | **保留** · sidebar 入口「图片生成」 | +| `design-system.html` | **保留** · 设计系统参考,非用户路径 | + +## 📌 sidebar NAV 当前入口对照(assets/shell.js:10-43) + +``` +工作台 → index.html ✓ +商品库 → products.html ✓ +视频项目 → projects.html ✓ +图片生成 → asset-factory.html ✓ +资产库 → library.html ✓ +团队 → team.html ✓ +消费 → account.html ✓ +设置 → settings.html ✓ +``` + +未在 sidebar 的"白名单"页面(从其他页面 link 进入): +- login.html / register.html(auth flow · 顶级入口) +- projects-new.html(项目向导 · 工作台「+新建项目」入口) +- pipeline.html(流水线 · 项目列表「打开」入口) +- product-detail.html(商品详情 · 商品库卡片入口) +- product-create.html stub / product-create-upload.html(drawer 流程辅助) +- model-photo.html / platform-cover.html(AI 素材生成入口) +- design-system.html(设计参考 · 非用户路径) diff --git a/core/frontend/public/exact/_archive/DESIGN_SPEC_V2.md b/core/frontend/public/exact/_archive/DESIGN_SPEC_V2.md new file mode 100644 index 0000000..834b55b --- /dev/null +++ b/core/frontend/public/exact/_archive/DESIGN_SPEC_V2.md @@ -0,0 +1,952 @@ +# 流·Studio 设计规范 · V2.1 + +> **版本:** V2.1(2026-05-15) +> **风格代号:** Restraint(克制)· Firecrawl-aligned +> **适用范围:** 流·Studio 全产品(工作台 / 项目 / 商品库 / 流水线 / 资产库 / 编辑器 / 账户) +> **V2.1 更新:** 色彩系统全面对齐 Firecrawl 实测——**冷灰底色**(放弃米白)· **主橙 #FA5D19**(从 #E55B26 调亮)· **20 档 black-alpha**(从 11 档扩展)· **5 色 accent 多彩点**(新增)· accent-black 替代 ink。保留 V2 所有结构性决策(8 px 圆角、inside-border、装订线、mono 装饰)。 +> **与 V2 关系:** V2.1 兼容 V2 的所有 token 命名(`--ink` `--bg` `--card` `--green` `--red` 等保留为 legacy 别名),代码层无需重写。 + +--- + +## 0. 变更速查 + +### V2 → V2.1(本次 · 色彩系统对齐 Firecrawl) + +| # | 维度 | V2 | V2.1 | +| - | ---- | -- | ---- | +| C1 | 底色 | `#FAF9F5` 暖米白 | **`#f9f9f9` 冷灰**(`--background-base`) | +| C2 | 主橙 | `#E55B26` 砖红 | **`#FA5D19`** 更红更饱和 | +| C3 | 文字基色 | `--ink: #15140F` | **`--accent-black: #262626`**(更柔和)· `--ink` 作 legacy 别名 | +| C4 | Alpha 阶梯 | 11 档 `--ink-alpha-*` | **20 档 `--black-alpha-*`**(1/2/3/4/5/6/7/8/10/12/16/20/24/32/40/48/56/64/72/88) | +| C5 | Alpha base | 全部用 `rgba(0,0,0,...)` | **32% 起换 `rgba(38,38,38,...)`**(避免叠出"灰中带蓝") | +| C6 | 状态色 | `--green #3F6B3F` 深森林绿 / `--red #B33A2A` 暗砖红 | **`--accent-forest #42c366`** / **`--accent-crimson #eb3424`**(信号灯感) | +| C7 | Accent 多彩 | 仅橙+绿+红 | **新增 5 色** amethyst/bluetron/crimson/forest/honey(仅用于语义信号) | +| C8 | 边框 | `#EFEBE0` 暖色 | **`#ededed`** 冷灰(无色相) | +| C9 | Selection | 未定义 | **`background: var(--heat-20)` + `color: var(--heat)`**(Firecrawl 签名) | + +### V1 → V2(上次 · 结构性变更,保留) + +| # | 维度 | V1 | V2 | 决策类型 | +| - | ---- | -- | -- | -------- | +| Δ1 | 圆角 | 大容器 0 / 按钮 9 / pill 999 | **大容器 8 / 按钮 8 / pill 999 / dot 999** | **变更** | +| Δ2 | mono `[ STATUS ]` | 大量使用 | **保留**(品牌签名) | 保留 | +| Δ3 | 字体 | Inter | **Inter Tight + 字重 500 替代 450**(免费) | 变更(轻) | +| Δ4 | 边框 | 真 `border:1px` | **`::before` inside-border**(hover 不抖动) | 变更 | +| Δ5 | 主容器左右垂直边 | 无 | **加 `border-x` + 四角准星**(图纸装订线) | 新增 | +| Δ6 | 主 CTA 阴影 | 全场无 | **4 层橙色发光** | 变更(小幅) | + +--- + +## 1. 设计哲学 + +**一句话:** 一台精密设备的工作面板。 + +**三条铁律:** +1. **克制大于装饰** — 留白 > 容器 > 内容,大量空气感。 +2. **单色锚点** — 全场只有一种 accent(橙色),且只用于 CTA / 关键状态 / 强调单词。 +3. **结构清晰可见** — 用 **1 px 边框 + 8 px 圆角 + 四角准星 + 容器纵向装订线 + mono `[ ]` 标签**暴露"图纸感",而非阴影/渐变隐藏结构。 + +**避免的"AI 味":** +- 渐变铺面 / 玻璃拟态 / 彩色阴影 +- 多色 emoji 图标 +- 圆角无差别(全部 8px / 16px 的 SaaS 模板感) +- 卡片浮在背景上的"贴纸感" +- 装饰盖过内容(场记板 / 霓虹 / 丝绒幕布) + +**新增禁令:** +- ❌ **0 px 硬切角的卡片** —— V2 起所有结构性容器都用 8 px 圆角 +- ❌ **变深 hue 的 hover 色** —— 橙色 hover 用 alpha 阶梯,不用更深的橙 + +--- + +## 2. 色彩系统(V2.1 · 对齐 Firecrawl) + +### 2.1 表面 / 背景(冷灰 · 无色相) + +| Token | Hex | 用途 | +| ---------------------- | --------- | --------------------------- | +| `--background-base` | `#f9f9f9` | 页面底色 · 冷灰 | +| `--background-lighter` | `#fbfbfb` | 容器底色 / hover 浅底 | +| `--surface` | `#ffffff` | 卡片 / 容器表面 | +| `--surface-raised` | `#ffffff` | 浮层 / Modal 表面 | + +> **Legacy 别名(V2 → V2.1):** `--bg = var(--background-base)` / `--bg-soft = var(--background-lighter)` / `--card = var(--surface)`。组件 CSS 不用改。 + +### 2.2 边框(冷灰 · 3 档差距极小) + +| Token | Hex | 用途 | +| ---------------- | --------- | --------------------- | +| `--border-faint` | `#ededed` | **默认 1 px 边框 ★ 80% 场景** | +| `--border-muted` | `#e8e8e8` | 略深 | +| `--border-loud` | `#e6e6e6` | 最深(强分隔) | + +> **设计意图:** 3 档之间只差 1–2 个色阶,**肉眼几乎看不出**。**用语义(faint/muted/loud)选择,不用视觉对比选择。** 这是 Firecrawl 的细节哲学。 + +### 2.3 Heat · 主橙(单 hue + 8 档 alpha) + +| Token | Value | 用途 | +| ----------- | ----------------------- | ----------------------------- | +| `--heat` | `#fa5d19` | **主橙 100% · CTA / 链接 ★** | +| `--heat-90` | `rgba(250,93,25,.90)` | hover(替代 V1 `#D04E1F`) | +| `--heat-40` | `rgba(250,93,25,.40)` | focus ring / 边框次级 | +| `--heat-20` | `rgba(250,93,25,.20)` | pill 边框 / **selection 底色 ★** | +| `--heat-16` | `rgba(250,93,25,.16)` | hover 软底 | +| `--heat-12` | `rgba(250,93,25,.12)` | tint 底(active nav / icon-box) | +| `--heat-8` | `rgba(250,93,25,.08)` | | +| `--heat-4` | `rgba(250,93,25,.04)` | 极弱底 | + +> **从 V2 `#E55B26` 调亮到 V2.1 `#FA5D19`** —— 更红更饱和,与 Firecrawl 100% 实测一致。**hover 永远不换 hue,只换 alpha。** + +### 2.4 Accent · 5 色信号(新增) + +> **作用:** 仅用于**语义信号**——代码高亮、图标色、状态色源。**禁止做大面积背景或装饰**。全场仍只有 1 个 accent(橙)。 + +| Token | Hex | 用途 | +| ------------------ | --------- | ----------------------------- | +| `--accent-black` | `#262626` | **主前景**(替代 V2 `--ink #15140F`,更柔和) | +| `--accent-white` | `#ffffff` | 反色文字(在橙底 / 黑底上) | +| `--accent-amethyst`| `#9061ff` | 紫 · 代码 property | +| `--accent-bluetron`| `#2a6dfb` | 蓝 · info | +| `--accent-crimson` | `#eb3424` | 红 · **error / 失败 ★** | +| `--accent-forest` | `#42c366` | 绿 · **success / 成功 ★** | +| `--accent-honey` | `#ecb730` | 黄 · warning | + +> **Legacy 别名:** `--ink = var(--accent-black)` / `--green = var(--accent-forest)` / `--red = var(--accent-crimson)`。 + +### 2.5 Black-Alpha 阶梯(20 档 · 核心工具尺) + +> **替代 V2 的 11 档 ink-alpha**。这是日常用得最多的 token。0–24% 用 `rgba(0,0,0,...)`;**32% 起换 `rgba(38,38,38,...)`**(=`--accent-black` 作底),避免叠出"灰中带蓝"——Firecrawl 实测细节。 + +| Token | Light 值 | Dark 值 | 典型用途 | +| ------------------ | ---------------------- | ---------------------- | ----------------------- | +| `--black-alpha-1` | `rgba(0,0,0,.01)` | `rgba(255,255,255,.01)`| 极弱底 | +| `--black-alpha-2` | `rgba(0,0,0,.02)` | `rgba(255,255,255,.02)`| | +| `--black-alpha-3` | `rgba(0,0,0,.03)` | `rgba(255,255,255,.03)`| | +| `--black-alpha-4` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.04)`| **hover bg ★** | +| `--black-alpha-5` | `rgba(0,0,0,.05)` | `rgba(255,255,255,.05)`| tab 间分隔条 | +| `--black-alpha-6` | `rgba(0,0,0,.06)` | `rgba(255,255,255,.06)`| | +| `--black-alpha-7` | `rgba(0,0,0,.07)` | `rgba(255,255,255,.07)`| **active bg ★** | +| `--black-alpha-8` | `rgba(0,0,0,.08)` | `rgba(255,255,255,.08)`| | +| `--black-alpha-10` | `rgba(0,0,0,.10)` | `rgba(255,255,255,.10)`| | +| `--black-alpha-12` | `rgba(0,0,0,.12)` | `rgba(255,255,255,.12)`| **inside-border ★** | +| `--black-alpha-16` | `rgba(0,0,0,.16)` | `rgba(255,255,255,.16)`| | +| `--black-alpha-20` | `rgba(0,0,0,.20)` | `rgba(255,255,255,.20)`| | +| `--black-alpha-24` | `rgba(0,0,0,.24)` | `rgba(255,255,255,.24)`| input hover 边框 | +| `--black-alpha-32` | `rgba(38,38,38,.32)` | `rgba(255,255,255,.32)`| ← base 切换 → ↓ | +| `--black-alpha-40` | `rgba(38,38,38,.40)` | `rgba(255,255,255,.40)`| | +| `--black-alpha-48` | `rgba(38,38,38,.48)` | `rgba(255,255,255,.48)`| **占位字色 ★** | +| `--black-alpha-56` | `rgba(38,38,38,.56)` | `rgba(255,255,255,.56)`| **次级文字 / 未选中 Tab ★** | +| `--black-alpha-64` | `rgba(38,38,38,.64)` | `rgba(255,255,255,.64)`| 描述文字 | +| `--black-alpha-72` | `rgba(38,38,38,.72)` | `rgba(255,255,255,.72)`| 强次级 | +| `--black-alpha-88` | `rgba(38,38,38,.88)` | `rgba(255,255,255,.88)`| 近主前景 | + +> **V2 → V2.1 兼容映射(legacy 别名,组件 CSS 不必改):** +> - `--ink-alpha-4` → `--black-alpha-4` +> - `--ink-alpha-7` → `--black-alpha-7` +> - `--ink-alpha-12` → `--black-alpha-12` +> - `--ink-alpha-24` → `--black-alpha-24` +> - `--ink-alpha-32` → `--black-alpha-32` +> - `--ink-alpha-48` → `--black-alpha-48` +> - `--ink-alpha-56` → `--black-alpha-56` +> - `--ink-alpha-64` → `--black-alpha-64` +> - `--ink-alpha-72` → `--black-alpha-72` +> - `--ink-alpha-88` → `--black-alpha-88` + +### 2.6 状态色配套底/边 + +| 含义 | 主色 | 配套底色(8% alpha) | 配套边框(20% alpha) | +| ---- | -------------------------- | --------------------------- | --------------------------- | +| 成功 | `--green` (`#42c366`) | `rgba(66,195,102,.08)` | `rgba(66,195,102,.20)` | +| 失败 | `--red` (`#eb3424`) | `rgba(235,52,36,.08)` | `rgba(235,52,36,.20)` | +| 信息 | `--heat` (`#fa5d19`) | `--heat-12` | `--heat-20` | +| 警告 | `--accent-honey`(`#ecb730`)| `rgba(236,183,48,.08)` | `rgba(236,183,48,.20)` | + +### 2.7 Selection · 文字选中色(Firecrawl 签名) + +```css +::selection { + background: var(--heat-20); + color: var(--heat); +} +``` + +选中任何文字时,底色 20% 橙、文字 100% 橙。这是 Firecrawl 易被忽略但**整站感知到位**的细节。 + +--- + +## 3. 字体系统(V2.1 · 中英混排策略) + +### 3.1 字体族 · Inter + 阿里巴巴普惠体 + +| 用途 | 字体声明 | +| ------------ | ------------------------------------------------------------------------------------------------- | +| **正文 / UI**| `'Inter', 'Alibaba PuHuiTi', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif` | +| **纯英文场景** | `'Inter', system-ui, sans-serif`(`--font-inter`)· 用于"Ctrl K"等强制纯英文徽标 | +| **Mono 装饰** | `'JetBrains Mono', 'Geist Mono', ui-monospace, monospace` | + +**核心策略 · 浏览器字符级 fallthrough:** + +``` +Inter ────────→ 英文 / 数字 / 符号(命中) + │ + ↓ (CJK 不命中,继续找) +Alibaba PuHuiTi ──→ 中文(命中) + │ + ↓ (字体未加载到) +PingFang SC / Microsoft YaHei ──→ 系统中文兜底 +``` + +浏览器对每个字符**逐个查找** font-family 链,Inter 不含 CJK 字形 → 中文字符自动跳到下一个候选。这是中英混排的标准做法,**不需要 JS、不会字重错位**。 + +**V2 → V2.1 → V2.1 变更轨迹:** +- V2: `Inter Tight` → 中文走系统 fallback → 字重错位 +- V2.1 (前一版): 单一 `Alibaba PuHuiTi` → 英文用普惠体英文字形 → 英文略圆,缺乏 Inter 的"科技感" +- **V2.1 (本版)** : **Inter(英)+ Alibaba PuHuiTi(中)双字体协作** → 各自处理最擅长的语种 + +**为什么这个组合最优:** +- Inter:Vercel / Linear / Stripe 御用,**工程产品默认审美**。专门给屏幕 UI 优化,数字字形漂亮(同宽)。中文不擅长。 +- Alibaba PuHuiTi:阿里出品,免费商用,**为中英混排专门设计的笔画粗细配比**(45/55/65/85 多档),中文笔画与 Inter 视觉重量贴近。专门给中文优化。 +- 两者结合:**英文有 Inter 的锐利,中文有普惠体的清晰**,字重之间衔接自然。 + +**Inter 载入:** + +```html + +``` + +**Alibaba PuHuiTi 载入:** + +```css +@font-face { + font-family: 'Alibaba PuHuiTi'; + font-weight: 400; /* 55 Regular */ + src: local('Alibaba PuHuiTi 3.0'), + local('AlibabaPuHuiTi-3-55-Regular'), + url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-55-Regular/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2'); +} +/* 同写法处理 500 Medium / 600 SemiBold / 700 Bold */ +``` + +> **优先级:** 本地安装(设计师机器一般装了)→ CDN 加载 → 系统中文兜底。**永远不会出现假字或方框。** + +**特殊场景 · 强制纯英文用 `--font-inter`:** + +某些场景必须确保用 Inter(比如 "Ctrl K" 这种快捷键徽标想要 Inter Bold 的紧凑感),直接用专属变量: + +```css +.search-wrap .k { + font-family: var(--font-inter); /* 跳过 fallback 链,锁定 Inter */ + font-weight: 700; + font-size: 11.5px; + letter-spacing: 0.02em; +} +``` + +Mono 字体保留 `JetBrains Mono`——用作装饰元素 `[ 200 OK ]` `// 05.14` `/v2`,**不参与中文**。 + +### 3.2 字号 / 字重 / 行高(V2.1 整体放大半档,行高提升) + +| 角色 | 字号 | 字重 | 字距 | 行高 | 用途 | +| ---------------- | ------- | ---- | ---------- | ---- | ---------------- | +| H1 / Hero | 36 px | 500 | -0.024em | 1.2 | 页面主标题(从 V2 的 32 上调) | +| 区块 H2 | 28 px | 500 | -0.02em | 1.25 | section-head 标题 | +| KPI 数值 | 32 px | 500 | -0.02em | 1.1 | 统计大数字 | +| 子区 H3 | 16 px | 500 | -0.01em | 1.4 | subsection 标题(从 15 上调) | +| 卡片标题 | 14 px | 500 | normal | 1.4 | 项目名 / 商品名(从 13.5 上调) | +| 正文 body | 14 px | 400 | normal | **1.65** | 默认正文(行高从 1.5 上调) | +| 区块描述 | 14 px | 400 | normal | 1.75 | section-head 描述 | +| 子区 lead | 13 px | 400 | normal | 1.8 | 子区下方说明 | +| Label(按钮/Tab)| 13 px | 500 | normal | 1.4 | 按钮文字 / Tab | +| Pill 文字 | 11.5 px | 500 | normal | 1.3 | 状态徽标 | +| Mono 标签 | 11–11.5 px | 400/500 | 0.04em | 1.5 | `[ STATUS ]` | +| Mono 散点 | 8.5 px | 400 | 0.04em | 1 | 背景 ASCII 装饰 | + +**字重档位仍仅 3 档:400 / 500 / 600**。普惠体 500(Medium 65)的中文笔画比 Inter 500 重一些,**整体不显单薄**,因此 600 几乎不需要。 + +**关键属性:** +- 数值类必须加 `font-variant-numeric: tabular-nums` +- 标题用 negative letter-spacing(-0.01 ~ -0.024em) +- 正文行高 **1.65–1.8**(V2 是 1.5–1.7),中文字间留呼吸 + +--- + +## 4. 圆角规则 · V2 统一 8 px + +> **核心原则(V2 改写):统一 8 px / 状态徽标完全圆 / 极少数微元素降到 4–6 px** + +| 元素类型 | 圆角值 | 例子 | +| ------------------------------ | ---------- | ------------------------------- | +| 所有结构性容器(大卡片 / 区块) | **8 px** | `.stats` `.list-card` `.shortcut` `.tip` `.modal` | +| 所有按钮 / 输入框 | **8 px** | `.btn` `.pill-btn` `.icon-btn` `.search` | +| nav 项 | **8 px** | `nav a`(V1 是 7,V2 统一) | +| 缩略图 / 画面占位 | **8 px** | `.thumb` `.ic` | +| 头像 / 小色块 | **6 px** | `.av` `.team .p`(可选 8) | +| Mono 标签 / badge / kbd | **4 px** | `.kbd` `.badge` | +| 进度条段位 | **2 px** | `.prog span` | +| Pill 状态徽标 / dot | **999 px** | `.pill` `.dot`(完全圆) | + +**为什么改:** Firecrawl 实测全站统一 8 px,工程感来自"准星 + 装订线 + mono 装饰",**不是硬切角**。0 圆角容器在小尺寸下会显得"卡顿、廉价",8 px 是同时兼顾"图纸感"和"成品感"的最佳值。 + +--- + +## 5. 边框 / 阴影 / 描边 + +### 5.1 边框策略 · V2 改为 inside-border + +> **V2 改进:** 默认边框用 `::before` 伪元素绘制,而非真 `border`。原因:hover 时让 `::before` 透明度 → 0 不会触发布局抖动。 + +**通用工具类:** + +```css +.inside-border { + position: relative; +} +.inside-border::before { + content: ''; + position: absolute; + inset: 0; + border: 1px solid var(--border-faint); + border-radius: inherit; + pointer-events: none; + transition: opacity .2s ease, border-color .2s ease; +} +.inside-border:hover::before { opacity: 0; } +``` + +**层级:** +- 默认:`var(--border-faint)` +- 略深:`var(--border-muted)`(主分隔线) +- 最深:`var(--border-loud)`(强分隔,少用) +- **禁止 2px / 3px 实线** +- **虚线**仅用于 `.tip` 提示框:`1px dashed var(--border-faint)` + +### 5.2 阴影 · V2 引入主 CTA 专属橙色发光 + +**默认无阴影**(V1 规则保留)。 + +**新增例外 · 主 CTA 4 层橙色阴影**(替代 V1 的"全场无"): + +```css +.btn-primary { + box-shadow: + inset 0 -4px 8px rgba(250, 93, 25, 0.20), /* 内阴影:底部暗一点 = 立体 */ + 0 1px 1px rgba(250, 93, 25, 0.12), + 0 2px 4px rgba(250, 93, 25, 0.10), + 0 0.5px 0.5px rgba(250, 93, 25, 0.16); +} +.btn-primary:hover { + box-shadow: + inset 0 -4px 8px rgba(250, 93, 25, 0.20), + 0 1px 1px rgba(250, 93, 25, 0.16), + 0 4px 8px rgba(250, 93, 25, 0.20), /* 更高更亮 */ + 0 0.5px 0.5px rgba(250, 93, 25, 0.16); +} +``` + +**Toast 阴影**(V1 保留):`0 4px 20px rgba(21,20,15,0.06)`,白色调,不属于橙色发光体系。 + +### 5.3 容器四角"+"准星(签名元素 · V2 升级为 SVG) + +V1 用字符 `+`(font-family JetBrains Mono);V2 升级为 SVG 路径,带圆弧内凹,质感更工程化: + +```html + + + +``` + +**位置:** 容器四角,中心精确落在边框交点上(用 `-translate-x-1/2 -translate-y-1/2`)。 + +**字符版本兼容:** 简单卡片(如 modal 内嵌)仍可用 `content: '+'` 字符版,但全屏主容器必须用 SVG。 + +--- + +## 6. 主内容容器 · 新增装订线规则(V2 新增章节) + +> **核心签名:** 主工作区始终被两条 1 px 垂直边线包夹,配合四角准星,形成图纸装订线效果。 + +```html +
+ ... + ... + ... + ... + +
+``` + +```css +.workbench-container { + max-width: 1480px; + margin: 0 auto; + border-left: 1px solid var(--border-faint); + border-right: 1px solid var(--border-faint); + position: relative; + /* 上下边线不要,只左右 */ +} +.workbench-container .corner { + position: absolute; + width: 22px; height: 21px; +} +.workbench-container .corner.top-left { top: -10.5px; left: -11px; } +.workbench-container .corner.top-right { top: -10.5px; right: -11px; } +.workbench-container .corner.bottom-left { bottom: -10.5px; left: -11px; } +.workbench-container .corner.bottom-right { bottom: -10.5px; right: -11px; } +``` + +适用范围:工作台 / 项目列表页 / 商品库 / 流水线主区。**编辑器全屏画布、Modal 内不必加。** + +--- + +## 7. 间距 / 栅格(V2.1 全局放大,Firecrawl-level 呼吸) + +**基础栅格:** 4 px + +**常用间距阶梯:** 4 / 8 / 12 / 16 / 20 / 24 / 28 / 32 / 40 / 48 / 64 / 80 / 104 + +> **V2 → V2.1 变更:** 全局垂直间距系统性放大 30%。原因:Alibaba PuHuiTi 中文笔画比 Inter 厚一档,**字体密度提升 → 必须用更大的留白补偿**,否则视觉拥挤。这就是为什么 Firecrawl 的间距看起来"奢侈"——它的字体也是出版级偏紧凑的 Suisse Intl,留白补偿到位。 + +**主区块布局:** +- 侧边栏宽度:`248 px`(无变) +- **主内容 padding:`72 px 80 px 120 px`**(从 V2 的 `48 56 56` 上调) +- 内容最大宽度:`1280 px`(从 1480 收窄,留更多自然边距) +- 区块间垂直:**`104 px`**(`section margin-bottom` · 从 80 上调) +- 子区间垂直:**`64 px`**(`subsection margin-bottom` · 从 48 上调) +- 子区标题底距:**`22 px`**(`h3 → 内容` · 从 16 上调) +- 卡片网格间距:`24 px`(主区) / `14 px`(子区) +- 卡片内 padding: + - 大卡片 / stats: **`28 px 30 px`**(从 22 24 上调) + - 列表行: **`20 px 24 px`**(从 14 18 上调) + - 快捷入口: **`18 px 20 px`**(从 14 上调) + - Modal 头/体: **`24 px 28 px`**(从 20 24 上调) + - Hero / section-head: **`52 px 56 px`** / `padding-bottom: 28 px` + +**最重要的一条:** 别再吝啬空气。**当不确定 padding 是 16 还是 24 时,选 24。** 当不确定 margin 是 48 还是 64 时,选 64。 + +**间距/字号配对原则:** +- 标题旁边的描述/副标:`mb 10–14 px`(紧凑组合) +- 标题下方的正文/列表:`mb 22–28 px`(分组组合) +- section 顶部 mono-tag → h2:`mb 14 px` +- h2 → section description:`mb 12 px` +- section-head 整体下方边线:`pb 28 px / mb 44 px` + +--- + +## 8. 背景:制图纸网格(V1 保留) + +### 8.1 三层叠加 + +``` +图层 1(最上):主交叉点 "+" 准星 SVG — 240×240 重复 +图层 2(中间):子交叉点小圆点 — 60×60 重复 +图层 3(最下):虚线网格 — 240×240 重复(stroke-dasharray: 1.5 4) +``` + +**配色:** +- "+" 准星:`#B8B3A4`(stroke 1 px) +- 小点:`#CFCABB`(r=0.9) +- 虚线:`#E2DED2`(stroke 1 px) + +### 8.2 视觉聚焦遮罩 + +```css +mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%); +``` + +### 8.3 装饰散点(Mono ASCII) + +主区域 4 个固定位置撒 ASCII 散点。字号 8.5 px / 颜色 `--ink-alpha-12` / 透明度 0.8 / `pointer-events: none`。 + +### 8.4 边角 Mono 标签(品牌签名 · 保留) + +主区域 4 个角各放一个 Mono 标签: +``` +左上 [ 200 OK ] 右上 [ /v2 ] +左下 [ .MP4 · 9:16 ] 右下 [ STUDIO ] +``` + +字号 10.5 px / 颜色 `--ink-alpha-48` / 字距 0.06em。 + +> **作用:** 让页面看起来像「在某个开发环境 / 调试视图里」,而不是普通官网。Firecrawl **没有**这个元素,这是流·Studio 的独特品牌资产。 + +--- + +## 9. Icon 系统(V2 强化章节,直接对应用户优化诉求 ①) + +### 9.1 通用规则 + +- **格式:** 一律 SVG inline,**禁止** `` 引图标 +- **库选择:** 推荐 Lucide(line icon,1.5–2 px stroke)或 Phosphor Regular +- **stroke width:** 统一 **1.5 px**(替代 V1 提到的 1.8) +- **stroke linecap / linejoin:** `round` +- **填充:** **不填充**(纯 line icon) +- **颜色:** 通过 `stroke="currentColor"`,继承父元素 `color` +- **emoji 禁用:** 任何场景都不允许彩色 emoji + +### 9.2 尺寸阶梯 + +| 场景 | 尺寸 | 用途 | +| ------------------- | ------- | ----------------------------- | +| Icon-S | 14 px | 内嵌 inline 文字旁 | +| Icon-M(默认) | 16 px | 按钮内 / Tab / list 行 | +| Icon-L | 20 px | 顶栏 / 快捷入口 / dropdown 触发器 | +| Icon-XL | 24 px | Modal 头部 / Toast / 空状态 | +| Icon-Hero | 36 px | 空状态插画 / 大占位 | + +### 9.3 颜色规则 + +| 场景 | 颜色 | +| ------------ | --------------------- | +| 默认 | `--ink-alpha-56` | +| Hover | `--ink` | +| Active / 选中| `--heat` | +| Disabled | `--ink-alpha-12` | +| 在主 CTA 内 | `#FFFFFF` | + +### 9.4 Icon-Box(快捷入口左侧的方块图标容器) + +```css +.icon-box { + width: 32px; + height: 32px; + border-radius: 8px; /* V1 是 0,V2 改 8 */ + background: var(--heat-12); + display: grid; + place-items: center; +} +.icon-box svg { width: 16px; stroke: var(--heat); } +``` + +--- + +## 10. 组件规范 · 含完整状态(V2 大幅扩充,对应用户诉求 ②③④) + +### 10.1 按钮(3 种类型 × 5 种状态 × 3 种尺寸) + +**类型:** + +| 类型 | 背景 | 文字 | 边框/描边 | +| ----------------- | ---------------- | ------------------ | ---------------------------------- | +| `.btn` 默认 | `--card` 白 | `--ink` | inside-border `--ink-alpha-12` | +| `.btn-primary` 主 | `--heat` | `#FFFFFF` | 无,靠橙色阴影分层 | +| `.btn-ghost` 无框 | transparent | `--ink-alpha-56` | 无 | + +**尺寸:** + +| 尺寸 | 高度 | padding | 字号 | icon 尺寸 | +| ------ | ----- | ------------ | ------- | --------- | +| `-sm` | 28 px | `0 10 px` | 12 px | 14 px | +| 默认 | 32 px | `0 14 px` | 13 px | 16 px | +| `-lg` | 40 px | `0 18 px` | 14 px | 16 px | + +**5 种状态(每种类型都要实现):** + +| 状态 | `.btn` 默认 | `.btn-primary` 主 | +| -------- | ------------------------------------------ | ---------------------------------------------- | +| Default | 白底 / inside-border `--ink-alpha-12` | 橙底 + 4 层橙阴影 | +| Hover | 底色 `--ink-alpha-4` + 边框 opacity → 0 | 阴影第 3 层加亮(`0 4px 8px rgba(229,91,38,0.20)`) | +| Active(按下) | 底色 `--ink-alpha-7` + `scale(0.99)` | `scale(0.995)` + 阴影 inset 加深 | +| Focused | 外层 2 px `--heat-40` ring,offset 2 px | 同左 | +| Disabled | 底 `--bg-soft` + 文字 `--ink-alpha-12` + `cursor: not-allowed` + 无 hover | 底 `--heat-40` + 文字 `#FFFFFF` + 阴影消失 + `cursor: not-allowed` | + +**过渡:** 全场统一 `transition: background-color 200ms, opacity 200ms, transform 100ms, box-shadow 100ms ease`。 + +### 10.2 Pill(状态徽标)· 严格分级(对应用户诉求 ②) + +> **V2 核心改进:Pill 分 3 级,同级别尺寸完全一致** + +| 级别 | 高度 | padding | 字号 | 圆角 | dot 尺寸 | 用途 | +| ------------ | ----- | ------------ | ------- | ----- | -------- | --------------------- | +| **L1 大胶囊** | 28 px | `0 12 px` | 13 px | 999 px| 8 px | 项目状态 / 列表行 | +| **L2 中胶囊** | 22 px | `0 10 px` | 11.5 px | 999 px| 6 px | **默认 / 通用** | +| **L3 小胶囊** | 18 px | `0 8 px` | 10.5 px | 999 px| 5 px | KPI 卡角标 / Mono 标签 | + +**色调(3 种,通用所有级别):** + +| 状态 | 文字色 | 底色 | 边框(20% alpha)| +| ---- | --------------- | ------------- | ---------------- | +| info | `--heat` | `--heat-12` | `--heat-20` | +| ok | `--green` | `--green-bg`(`#3F6B3F14`) | `--green-bd`(`#3F6B3F33`) | +| err | `--red` | `--red-bg`(`#B33A2A14`) | `--red-bd`(`#B33A2A33`) | + +每个 pill 前置圆点(`.dot`,直径同上表,颜色继承文字色)。 + +**HTML 写法:** + +```html + + + 生成中 + +``` + +### 10.3 输入框 / 搜索(V2.1 · 含 Firecrawl 式快捷键提示) + +**尺寸:** 高 **36 px**(从 V2 的 32 上调,中文留白更舒展)/ padding `0 14 px` / 字号 14 px / 圆角 8 px + +**状态:** + +| 状态 | 边框 | 底色 | +| -------- | --------------------------------- | --------------------- | +| Default | inside-border `--black-alpha-12` | `--card` | +| Hover | inside-border `--black-alpha-24` | `--card` | +| Focused | inside-border `--heat-40` + inset 1 px | `--card` | +| Error | inside-border `--red` | `--red-bg` | +| Disabled | inside-border `--black-alpha-12` | **`--black-alpha-5`**(从 `--bg-soft` 改 · 冷灰底太接近白,看不出禁用) | + +占位字色 `--black-alpha-48`,disabled 占位 `--black-alpha-24`。 + +**带图标 / 快捷键搜索框(参考 Firecrawl 实测):** + +``` +[🔍] [ 搜索任意内容... ] Ctrl K + │ │ + │ 16px line icon, color: --black-alpha-56 │ mono · 11.5px · --black-alpha-48 + │ 左 14 px,**z-index: 2**(关键) │ 右 14 px,**z-index: 2** + │ │ 平铺文本(无边框盒),不用 kbd 样式 +``` + +**关键坑(已修):** +1. **搜索 icon 看不见** —— `` 的白色 bg 会盖住同级的 SVG icon。**SVG 必须加 `z-index: 2`** 才能抬到 input 之上。同理 Ctrl K 提示也要 z-index。 +2. **快捷键不用 `⌘`** —— JetBrains Mono webfont 不带 U+2318 `⌘` 字形,会显示成方框。**Windows 用户体系用 "Ctrl K" 纯文本**;Mac 端要显示 ⌘ 时用 SVG command 图标(见 §9.4)。 +3. **快捷键提示不要用 kbd 边框盒** —— Firecrawl 是平铺灰色 mono 文本,无任何 border / bg / radius。**显得克制,符合"克制大于装饰"。** + +### 10.4 KPI 统计行(`.stats`) + +- 1 行 4 格,共用一个 inside-border 容器,圆角 8 px,**无 gap** +- 列与列之间用 `border-right: 1px solid var(--border-faint)` 分隔(最后一列去掉) +- 容器四角加 SVG "+" 准星 +- 每格结构:label + L3 pill → 大数字(30 px) → delta / progress + +### 10.5 列表行(`.list-row`) + +``` +[缩略图 54×70] [标题 + meta] [进度条 5 段] [L2 pill] [按钮 -sm] +``` + +- grid 5 列:`54px 1fr auto auto auto` +- 行高 padding `14 px 18 px` +- 行间 1 px 分隔线(`--border-faint`),最后行去掉 +- **整行 hover** → `--ink-alpha-4` 底色 +- **整行 active**(键盘选中)→ `--ink-alpha-7` 底色 +- **整行 disabled** → opacity 0.5 + `cursor: not-allowed` + +### 10.6 进度条段位(流水线 5 阶段专用)· **V2.1 语义色重写** + +5 个 `18×5 px` 小段,3 px 间距,**每段颜色映射真实业务状态**(替代 V2 的"全灰已完成"——那样像项目失败): + +| 状态 | 颜色 | 视觉处理 | +| -------- | --------------------- | ------------------------------ | +| 未开始 | `--black-alpha-8` | 极弱灰底 · 静态 | +| **已完成** | `--accent-forest`(`#42c366`)| **绿色 · 静态**(替代 V2 灰色) | +| **进行中** | `--heat`(`#fa5d19`) | **橙色 + 1.4s 脉动**(opacity 1↔0.55 + scaleY 1↔0.7)· 区别于失败 | +| 失败 | `--accent-crimson`(`#eb3424`)| 红色 · 静态 | + +圆角:每段 2 px。 + +**关键设计:** +- **绿/橙/红/灰** 四色一眼可辨:已完成的绿色让"5/5 全绿"=完美交付,**不会再有"全灰像失败了"的误读** +- **橙色脉动动画** 是区分"进行中"与"失败"的关键——红色永远静态,只有橙色会呼吸,潜意识上"动 = 在运行,静 = 出错" + +```css +.prog span.now { + background: var(--heat); + animation: prog-pulse 1.4s ease-in-out infinite; +} +@keyframes prog-pulse { + 0%,100% { opacity: 1; transform: scaleY(1); } + 50% { opacity: .55; transform: scaleY(.7); } +} +``` + +### 10.7 快捷入口卡(`.shortcut`) + +- 白底 / inside-border / **8 px 圆角**(V1 是 0) / 14 px padding +- 左侧 32×32 橙色 tint 图标块(`--heat-12` 底 / 8 px 圆角 / 居中 16 px line icon) +- 右侧:标题(13 px 500)+ Mono 描述(11.5 px,`--ink-alpha-48`) +- **Default → Hover → Active 完整状态:** + - Hover:底色 `--ink-alpha-4` / 标题 underline-from-orange 1 px(可选) + - Active(点击瞬间):scale(0.99) + - Focused(键盘):2 px `--heat-40` ring + +### 10.8 提示框(`.tip`) + +- 白底 / **1 px 虚线**边框(`dashed`)/ **8 px 圆角** +- 加粗标题独立一行 + 正文 +- 内联代码用 `.mono` 类:橙色文字 + `--heat-12` 底 + 4 px 圆角 + +### 10.9 Toast + +- 右下角 24 px 偏移 / 白底 / inside-border / **8 px 圆角** +- 唯一允许的"白阴影":`0 4px 20px rgba(21,20,15,0.06)` +- 进入动画:`translateX(420px → 0)`,缓动 `cubic-bezier(0.34, 1.56, 0.64, 1)`,300 ms +- 自动消失:2400 ms +- 内容结构:左侧 24×24 橙色 tint 图标(`--heat-12` 底)+ 右侧标题 + Mono 副文本(`[ 200 OK ]`) + +### 10.10 弹窗(Modal) + +- 居中,460–480 px 宽,白底,**8 px 圆角**(V1 是 0) +- 四角加 SVG "+" 准星 +- 遮罩 `rgba(21,20,15,0.42)`,带 `backdrop-filter: blur(8px)` +- 进入动画:`scale(0.96 → 1)`,250 ms 弹性缓动 +- 三段结构: + - **Header:** 36 px 橙色 tint 图标 + 标题 + Mono 副标 + - **Body:** 13 px / `--ink-alpha-56` / 行高 1.7 + - **Footer:** 右对齐两个按钮(取消 + 主 CTA) +- ESC 关闭 / 点击遮罩关闭 + +### 10.11 Tab(双层) + +**主 Tab:** +- 高 36 px / padding `0 14 px` / 字号 13 px / 字重 500 +- 未选中:`--ink-alpha-56` 文字 +- 选中:`--ink` 文字 + 底部 2 px `--heat` 横线(整个 tab 宽度) +- Hover(未选中态):`--ink-alpha-7` 底色 + +**副 Tab(过滤型):** +- 高 28 px / padding `0 10 px` / 字号 12 px +- 未选中:`--ink-alpha-56` + 灰度 icon +- 选中:`--ink` + 多色 icon(grayscale: 0) +- Tab 之间用 1 px 高 12 px 的 `--ink-alpha-7` 竖条分隔(可选) + +### 10.12 Dropdown / Select + +- 触发器:同输入框样式(高 32 / 圆角 8 / inside-border) +- 右侧 16 px chevron-down icon,色 `--ink-alpha-48`,展开时旋转 180° +- 弹层:白底 / 8 px 圆角 / `0 4px 20px rgba(21,20,15,0.06)` 阴影 / **唯一允许阴影场景之二** +- 选项:高 32 / padding `0 12 / hover `--ink-alpha-4` / 选中 `--heat-12` + 右侧 14 px checkmark `--heat` +- 分组分隔:1 px `--border-faint` + +### 10.13 Checkbox / Radio / Switch(V2.1 · 全部用 SVG icon) + +**通用原则:** 所有 indicator 都用真 SVG(via background-image data URI 或 inline ``),**禁止用 border-width / transform: rotate(45deg) 凑对勾**——那种 CSS hack 在缩放/字体渲染下会变形。 + +**Checkbox:** +- **容器:** 16×16 / 4 px 圆角 / inside-border `--black-alpha-24`(hover 时 → `--black-alpha-48`) +- **Checked:** `--heat` 底 · 居中 12×12 白色 SVG checkmark(`` · stroke 3 · linecap round) +- **Indeterminate:** `--heat` 底 · 居中 12×12 白色 SVG 横线(`` · stroke 3) +- **Disabled:** 底 `--black-alpha-5` + 边 `--black-alpha-12` + icon 不渲染或半透明 +- **实现方式:** `background-image: url("data:image/svg+xml,...")` · `background-size: 12px 12px` · `background-position: center` · 这样不需要 ::after 凑出 icon,SVG 是真实的 + +**Radio:** +- 16×16 圆 / inside-border `--black-alpha-24` +- Checked:内嵌 8×8 `--heat` 实心圆(纯几何形状,不算 icon,用 ::after 即可) +- Disabled:底 `--black-alpha-5` + +**Switch:** +- 容器 28×16 / 999 px 圆角 +- Off:底 `--black-alpha-12` / 圆球 `#FFFFFF` 12×12 + 1 px subtle shadow +- On:底 `--heat` / 圆球右移到 `left: 14px` +- 过渡:`background 200 ms`,圆球位移 `left 200 ms` + +> **为什么不用字符 `✓` `✗` 这种 Unicode:** 不同系统、不同字体对这些字符的字形支持差异巨大(`⌘` 在 JetBrains Mono Web 字体里就是缺字形 → 显示成方框)。**SVG 是唯一可靠的解。** + +--- + +## 11. 微观细节(品牌签名 · V1 保留) + +### 11.1 标签全部包方括号 +`[ 200 OK ]` `[ .MP4 · 9:16 ]` `[ /v2 ]` `[ /sidebar collapse ]` + +### 11.2 时间戳像代码注释 +`// 05.14 · 周五` + +### 11.3 数值后缀 +`¥327` 主体大字 + `.40` 小字次级(``) + +### 11.4 强调单词上色 +正文里 `3 个项目` —— 让具体数值/名词比周围文字更深一档(不是变橙)。**橙色只留给 CTA。** + +### 11.5 ASCII 字符做装饰 +`↑ 本月 +3` `↓ -1.2%` + +### 11.6 链接式"更多"按钮 +`[ ALL · 12 ] →` —— Mono 标签 + 箭头,色 `--ink-alpha-48`,hover 转橙。 + +### 11.7 缩略图不放图,放比例 +`9:16` Mono 字符占位。 + +--- + +## 12. Don't List(绝对禁止) + +- ❌ **0 px 卡片**(V1 → V2 反转) +- ❌ 渐变背景(只有 hero 区可考虑,但首选纯色) +- ❌ 玻璃拟态(`backdrop-filter` 只用于 modal 遮罩) +- ❌ 彩色 emoji 图标(用 SVG line icon,1.5 px stroke) +- ❌ 多个 accent 色(全场只有橙色) +- ❌ 大圆角容器(>12 px 直接判错) +- ❌ 灰色阴影 / 文字阴影(只允许橙色主 CTA 阴影 + Toast/Dropdown 白阴影) +- ❌ 鲜艳的状态色(避免荧光绿、电光蓝、霓虹粉) +- ❌ 居中对齐大段正文(全部左对齐) +- ❌ 把装饰当主角(场记板、丝绒、霓虹灯) +- ❌ 无意义的微动效(hover 旋转、缩放、彩虹流光) +- ❌ **hover 时换深 hue 的橙**(用 alpha) +- ❌ **真 `border` + hover 边框消失**(用 inside-border ::before) +- ❌ **同一行混用直角和圆角**(用户原话:"不要有些是直角,胶囊又是圆角") + +--- + +## 13. Sass / CSS Token 速查表(V2.1) + +```css +:root { + /* ============================================================ + Color system V2.1 · Firecrawl-aligned + ============================================================ */ + + /* === 表面 / 背景(冷灰)=== */ + --background-base: #f9f9f9; + --background-lighter: #fbfbfb; + --surface: #ffffff; + --surface-raised: #ffffff; + + /* === 边框(冷灰 · 3 档)=== */ + --border-faint: #ededed; /* 默认 1 px */ + --border-muted: #e8e8e8; + --border-loud: #e6e6e6; + + /* === Accent 多彩(5 色信号)=== */ + --accent-black: #262626; + --accent-white: #ffffff; + --accent-amethyst: #9061ff; + --accent-bluetron: #2a6dfb; + --accent-crimson: #eb3424; + --accent-forest: #42c366; + --accent-honey: #ecb730; + + /* === Heat · 单 hue + 8 档 alpha === */ + --heat: #fa5d19; + --heat-90: rgba(250, 93, 25, .90); + --heat-40: rgba(250, 93, 25, .40); + --heat-20: rgba(250, 93, 25, .20); + --heat-16: rgba(250, 93, 25, .16); + --heat-12: rgba(250, 93, 25, .12); + --heat-8: rgba(250, 93, 25, .08); + --heat-4: rgba(250, 93, 25, .04); + + /* === Black-Alpha 阶梯(20 档 · 0–24 用 #000,32+ 用 #262626)=== */ + --black-alpha-1: rgba(0, 0, 0, .01); + --black-alpha-2: rgba(0, 0, 0, .02); + --black-alpha-3: rgba(0, 0, 0, .03); + --black-alpha-4: rgba(0, 0, 0, .04); + --black-alpha-5: rgba(0, 0, 0, .05); + --black-alpha-6: rgba(0, 0, 0, .06); + --black-alpha-7: rgba(0, 0, 0, .07); + --black-alpha-8: rgba(0, 0, 0, .08); + --black-alpha-10: rgba(0, 0, 0, .10); + --black-alpha-12: rgba(0, 0, 0, .12); + --black-alpha-16: rgba(0, 0, 0, .16); + --black-alpha-20: rgba(0, 0, 0, .20); + --black-alpha-24: rgba(0, 0, 0, .24); + --black-alpha-32: rgba(38, 38, 38, .32); + --black-alpha-40: rgba(38, 38, 38, .40); + --black-alpha-48: rgba(38, 38, 38, .48); + --black-alpha-56: rgba(38, 38, 38, .56); + --black-alpha-64: rgba(38, 38, 38, .64); + --black-alpha-72: rgba(38, 38, 38, .72); + --black-alpha-88: rgba(38, 38, 38, .88); + + /* === Legacy aliases(V2 命名 → V2.1 token,组件 CSS 无需重写)=== */ + --bg: var(--background-base); + --bg-soft: var(--background-lighter); + --card: var(--surface); + --ink: var(--accent-black); + --green: var(--accent-forest); + --red: var(--accent-crimson); + --green-bg: rgba(66, 195, 102, .08); + --green-bd: rgba(66, 195, 102, .20); + --red-bg: rgba(235, 52, 36, .08); + --red-bd: rgba(235, 52, 36, .20); + --ink-alpha-4: var(--black-alpha-4); + --ink-alpha-7: var(--black-alpha-7); + --ink-alpha-12: var(--black-alpha-12); + --ink-alpha-24: var(--black-alpha-24); + --ink-alpha-32: var(--black-alpha-32); + --ink-alpha-48: var(--black-alpha-48); + --ink-alpha-56: var(--black-alpha-56); + --ink-alpha-64: var(--black-alpha-64); + --ink-alpha-72: var(--black-alpha-72); + --ink-alpha-88: var(--black-alpha-88); + + /* === 圆角 === */ + --r-sm: 4px; + --r-md: 8px; /* 默认主圆角 */ + --r-pill: 999px; + + /* === 字体 === */ + --font-sans: 'Inter Tight', 'PingFang SC', 'Microsoft YaHei', sans-serif; + --font-mono: 'JetBrains Mono', 'Geist Mono', monospace; + + /* === 容器宽度 === */ + --container-max: 1480px; + --sidebar-w: 248px; + + /* === 过渡 === */ + --t-fast: 100ms ease; + --t-base: 200ms ease; + --t-slow: 300ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* === Selection · Firecrawl 签名细节 === */ +::selection { + background: var(--heat-20); + color: var(--heat); +} + +/* === Dark mode · 翻转底色 + black-alpha 改用 white-alpha === */ +.dark { + --background-base: #0a0a0a; + --background-lighter: #141414; + --surface: #171717; + --surface-raised: #1f1f1f; + --border-faint: #2a2a2a; + --border-muted: #333333; + --border-loud: #404040; + --accent-black: #f5f5f5; + --black-alpha-1: rgba(255,255,255,.01); + --black-alpha-2: rgba(255,255,255,.02); + --black-alpha-3: rgba(255,255,255,.03); + --black-alpha-4: rgba(255,255,255,.04); + --black-alpha-5: rgba(255,255,255,.05); + --black-alpha-6: rgba(255,255,255,.06); + --black-alpha-7: rgba(255,255,255,.07); + --black-alpha-8: rgba(255,255,255,.08); + --black-alpha-10: rgba(255,255,255,.10); + --black-alpha-12: rgba(255,255,255,.12); + --black-alpha-16: rgba(255,255,255,.16); + --black-alpha-20: rgba(255,255,255,.20); + --black-alpha-24: rgba(255,255,255,.24); + --black-alpha-32: rgba(255,255,255,.32); + --black-alpha-40: rgba(255,255,255,.40); + --black-alpha-48: rgba(255,255,255,.48); + --black-alpha-56: rgba(255,255,255,.56); + --black-alpha-64: rgba(255,255,255,.64); + --black-alpha-72: rgba(255,255,255,.72); + --black-alpha-88: rgba(255,255,255,.88); +} +``` + +--- + +## 14. V1 → V2 迁移检查清单(给后续改代码用) + +- [ ] 全局替换 `border-radius: 0` → `border-radius: 8px`(卡片 / stats / shortcut / modal / toast / thumb) +- [ ] 替换 V1 ink-2/3/4 token 为 ink-alpha-56/48/12 +- [ ] 替换 `--orange-tint` `--orange-soft` 为 `--heat-12` `--heat-20` +- [ ] 主 CTA hover 移除 `#D04E1F`,改用 4 层橙阴影变化 +- [ ] 所有 `.btn` `.input` 加 `inside-border` 类 +- [ ] 主工作区容器加 `border-left + border-right` + 4 个 SVG 准星 +- [ ] 字体 Inter → Inter Tight +- [ ] 所有 icon 转 SVG line icon,stroke 1.5 +- [ ] Pill 按 L1/L2/L3 三级规范化高度/字号/圆点尺寸 +- [ ] 每个交互组件补齐 hover / active / focused / disabled 状态 + +--- + +## 15. 参考与来源 + +- **视觉灵感(实测):** [Firecrawl Playground](https://www.firecrawl.dev/playground?endpoint=parse) · 详见 [firecrawl_playground_spec.md](_design_src/firecrawl_playground_spec.md) +- **结构灵感:** Linear / Stripe Dashboard +- **图纸感来源:** 印刷套版准星 + 老 Unix 终端 +- **V1 文档:** [DESIGN_SPEC.md](DESIGN_SPEC.md)(保留作为历史) diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/README.md b/core/frontend/public/exact/_archive/deprecated-pages-20260528/README.md new file mode 100644 index 0000000..3b64fbd --- /dev/null +++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/README.md @@ -0,0 +1,16 @@ +# Deprecated Static Pages Archive · 2026-05-28 + +These pages were moved out of the active static app because the current +navigation and production prototype use `products.html`, `product-detail.html`, +`pipeline.html`, `asset-factory.html`, `model-photo.html`, and +`platform-cover.html` instead. + +Archived pages: +- `product-create.legacy.html` +- `product-create-v2.html` +- `product-studio.html` +- `studio.html` +- `studio-v2.html` + +Keep them only as reference material. They should not be linked from the active +Airshelf static flow. diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create-v2.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create-v2.html new file mode 100644 index 0000000..ae0e184 --- /dev/null +++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create-v2.html @@ -0,0 +1,414 @@ + + + + +新建商品 · Airshelf + + + + + +
+ +
+
+

新建商品

+
// 上传原图 + 填写基本信息 · 保存后可在工作台逐步丰富素材
+
+
+ + +
+ + +
+
+

商品原图

+ 必填 +
+
// 1-5 张 · 这是后续所有 AI 生成的源材料
+ +
+
+ + 建议上传 正面 / 侧面 / 细节 / 包装 4 张,后续在工作台生成的白底三视图更准确。 +
+
+ + +
+
+

基本信息

+ 必填 +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+

卖点 & 人群

+ 选填 · 推荐 +
+
+ + 填上这两项,后续 AI 生脚本(痛点种草 / 剧情带货 等模板)质量明显更高 —— 系统会用卖点构造钩子,用人群定语气。现在不填也可以,做视频项目时仍可补。 +
+ +
+ +
3-5 条要点,回车添加
+
    +
  • +
  • +
+
+
+ + +
+
+ + +
+ // 必填检查:商品名 / 品类 / ≥1 张图 未完成 +
+ 取消 + +
+
+ +
+ + + + + diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create.legacy.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create.legacy.html new file mode 100644 index 0000000..022d1d5 --- /dev/null +++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create.legacy.html @@ -0,0 +1,3884 @@ + + + + +新建商品 · Airshelf + + + + + + + +
+ + +
+ + + + + + + + + diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-studio.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-studio.html new file mode 100644 index 0000000..7f9e640 --- /dev/null +++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-studio.html @@ -0,0 +1,1085 @@ + + + + +商品工作台 · Airshelf + + + + + +
+ +
+
+

透真补水面膜

+
// 商品工作台 · 已生成 11 张资产 · 创建于 5/19
+
+ +
+ + + + + +
+
+
+
+ +
+
+
透真玻尿酸补水面膜
+
[ 美妆个护 ] · ¥39.9 · 22-32 岁女性
+
+ +
+
+ 熬夜党 + 敏感肌 + 补水 + 玻尿酸 + 通勤 +
+
+ // 卖点 + 玻尿酸双效保湿 · 4 小时持久水润 · 敏感肌可用 · 通勤补水 · 平价代替 +
+
+ +
+
+ 原图册 · 3 / 5 + JPG / PNG · ≤ 5MB +
+
+
+ MAIN +
+
+
+
+ +
+
+
+
+ + +
+

AI 工具箱

+ // 按需调用 · 生成结果自动入资产库 · 失败不扣费 +
+
+ + + +
+
+ +
+
+
AI 模特上身图
+
8 模特库可选 · 每次 4 张
+
+
+ ~35 秒 · ¥3.2 + +
+
+ +
+
+ +
+
+
平台套图
+
6 平台规格 · 每平台 4 张
+
+
+ ~28 秒 · ¥2.4 + +
+
+ +
+
+ +
+
+
更多工具
+
卖点海报 / 详情页插画
+
+
+ 即将上线 +
+
+ +
+ + +
+
+

已生成资产

+ // 自动入「资产库 / 跨项目共享 / 商品图」 +
+
+ + + + +
+
+
+ +
+ + +
+
+ +
+
+
选择模特
// 系统已有 8 位模特 · 每次生成 4 张上身图
+ +
+
+
+
+
+ +
+ 室内自拍 + 梳妆台 + 户外清晨 + 纯色背景 +
+
+
+ +
+ 半身 + 特写 + 全身 +
+
+
+ + 4 张 · 不满意可原地重跑 +
+
+
+
+ // 预计耗时 35 秒 · ¥3.2 · 失败不扣费 + + +
+
+
+ + +
+
+ +
+
+
选择平台
// 按平台调性 + 比例生成 · 每平台 4 张
+ +
+
+
+
+
+ +
+ 主图(1:1) + 详情页头图(750×1000) + 活动横幅(750×260) +
+
+
+ +
+ 干净电商 + 种草风 + 节日热闹 +
+
+
+ + 4 张 · 不满意可原地重跑 +
+
+
+
+ // 预计耗时 28 秒 · ¥2.4 · 失败不扣费 + + +
+
+
+ + +
+
+ +
+
+
生成白底三视图
// 单张 16:9 · 正 / 侧 / 背 合一
+ +
+
+
+ AI 会以「主图」为基准,自动去白底 + 重打光 + 推算另外两个视角。商品形态尽量保持稳定。 +
+
+
+ +
+ 正面 + 侧面 + 背面 +
+
+
+ +
+ 纯白 + 浅灰 + 柔和渐变 +
+
+
+
+
+ // 预计 18 秒 · ¥1.6 + + +
+
+
+ + + + + + diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio-v2.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio-v2.html new file mode 100644 index 0000000..f7d0d08 --- /dev/null +++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio-v2.html @@ -0,0 +1,1403 @@ + + + + +商品工作台 V2 · Airshelf + + + + + +
+ +
+ + + + +
+
+ +
+ + +
+
+
+
+
选择模特
// 8 位模特可选
+
+ +
+
+
+
+
+ +
+
+
+
+ // ~35 秒 · ¥3.2 + + +
+
+
+
+ + + + + + diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio.html new file mode 100644 index 0000000..7490fb7 --- /dev/null +++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio.html @@ -0,0 +1,1635 @@ + + + + +商品工作台 · Airshelf + + + + + +
+ +
+ + + + +
+
+ +
+ + +
+
+
+
+
生成白底三视图
// 透真补水面膜
+ +
+
+
+ AI 会以「主图」为基准,自动去白底 + 重打光 + 推算另外两个视角。 +
+
+
+ +
+ 正面 + 侧面 + 背面 +
+
+
+ +
+ 纯白 + 浅灰 + 柔和渐变 +
+
+
+
+
+ // 预计 18 秒 · ¥1.6 + + +
+
+
+ +
+
+
+
+
选择模特
// 8 位模特 · 每次 4 张
+ +
+
+
+
+
+ // 预计 35 秒 · ¥3.2 · 失败不扣费 + + +
+
+
+ +
+
+
+
+
选择平台
// 每平台 4 张
+ +
+
+
+
+
+ // 预计 28 秒 · ¥2.4 · 失败不扣费 + + +
+
+
+ + + + + + diff --git a/core/frontend/public/exact/_archive/design-system.html b/core/frontend/public/exact/_archive/design-system.html new file mode 100644 index 0000000..37fd368 --- /dev/null +++ b/core/frontend/public/exact/_archive/design-system.html @@ -0,0 +1,1730 @@ + + + + + +流·Studio 设计系统 V2 · Interactive + + + + + + + +
+ + + + + +
+ + +
+ // V2 · INTERACTIVE +

流·Studio 设计系统

+

所有 token、组件、状态的可交互参照。基于 Firecrawl Playground 实测规范校准。 hover / 点击下方组件可看到真实交互反馈。

+
+ [ Restraint · v2.0 ] + · + // based on DESIGN_SPEC_V2.md +
+
+ + + + +
+
+ // §1 · COLOR TOKENS · V2.1 · FIRECRAWL-ALIGNED +

色彩系统

+

V2.1 全面对齐 Firecrawl Playground 实测色板:**冷灰底**(非米白)· **#FA5D19 主橙**(更亮一档)· **20 档 black-alpha 阶梯**(替代 11 档 ink-alpha)· **5 色 accent 多彩点**(amethyst / bluetron / crimson / forest / honey)。点击任何色块复制值。

+
+ +
+

表面 / 背景 // 4 档 · 冷灰无色相

+

告别 V2 的暖米白(#FAF9F5),全面切换 Firecrawl 的纯冷灰。--bg/--bg-soft/--card 作为 legacy 别名仍可用。

+
+
--background-base
#f9f9f9 · 页面底
copied
+
--background-lighter
#fbfbfb · 容器底
copied
+
--surface
#ffffff · 卡片
copied
+
--surface-raised
#ffffff · Modal
copied
+
+
+ +
+

边框 // 3 档 · 冷灰 · 差距极小靠语义

+

3 档相差只 1–2 个色阶,肉眼几乎看不出。**用语义,不用视觉对比**——80% 场景用 --border-faint

+
+
--border-faint
#ededed · 默认 ★
copied
+
--border-muted
#e8e8e8 · 略深
copied
+
--border-loud
#e6e6e6 · 强分隔
copied
+
+
+ +
+

主橙 Heat // 单 hue #FA5D19 + 8 档 alpha

+

从 V2 砖红 #E55B26 调亮到 Firecrawl 实测 #FA5D19(更红更饱和)。**全靠 alpha 叠加,绝不换 hue。** hover 不再切换到更深的橙,而是用 90% / 16% 这些档位组合。

+
+
--heat
#fa5d19 · 100% · CTA ★
copied
+
--heat-90
90% · hover
copied
+
--heat-40
40% · ring / edge
copied
+
--heat-20
20% · pill border / selection
copied
+
--heat-16
16% · hover bg
copied
+
--heat-12
12% · tint bg
copied
+
--heat-8
8%
copied
+
--heat-4
4% · 极弱
copied
+
+
+ +
+

Accent 多彩点 // 5 色信号 · 限定语义场景

+

**新增章节 · 取自 Firecrawl 实测**。这 5 色只用于**语义信号**(代码高亮 / info / 状态色),**禁止做大面积装饰**——全场依然只有橙色一个 accent。--accent-black 替代 V2 的 --ink #15140F(更柔和的灰黑)。

+
+
--accent-black
#262626 · 主前景
copied
+
--accent-white
#ffffff · 反色文字
copied
+
--accent-amethyst
#9061ff · 紫 / code property
copied
+
--accent-bluetron
#2a6dfb · 蓝 / info
copied
+
--accent-crimson
#eb3424 · 红 / error ★
copied
+
--accent-forest
#42c366 · 绿 / success ★
copied
+
--accent-honey
#ecb730 · 黄 / warning
copied
+
+
+ +
+

Black-Alpha 阶梯 // 20 档 · 替代 V2 的 11 档 ink-alpha

+

**核心工具尺。** 0–24% 用 rgba(0,0,0,...) 纯黑透明;**32% 起换 rgba(38,38,38,...)**(即 accent-black 作底)避免叠出"灰中带蓝"——这是 Firecrawl 的细节技巧,我们 1:1 复刻。dark mode 时这套 token 自动翻转为 white-alpha。原 --ink-alpha-* 全部作为 legacy 别名映射到对应 black-alpha。

+
+
--black-alpha-1
1%
copied
+
--black-alpha-2
2%
copied
+
--black-alpha-3
3%
copied
+
--black-alpha-4
4% · hover bg ★
copied
+
--black-alpha-5
5% · tab 分隔
copied
+
--black-alpha-6
6%
copied
+
--black-alpha-7
7% · active bg ★
copied
+
--black-alpha-8
8%
copied
+
--black-alpha-10
10%
copied
+
--black-alpha-12
12% · inside-border ★
copied
+
--black-alpha-16
16%
copied
+
--black-alpha-20
20%
copied
+
--black-alpha-24
24%
copied
+
--black-alpha-32
32% · base 切换 →
copied
+
--black-alpha-40
40%
copied
+
--black-alpha-48
48% · 占位字色 ★
copied
+
--black-alpha-56
56% · 次级文字 ★
copied
+
--black-alpha-64
64% · 描述
copied
+
--black-alpha-72
72%
copied
+
--black-alpha-88
88% · 近主前景
copied
+
+
+ +
+

状态色 // 用 accent-forest / crimson 作语义

+

V2 的 --green #3F6B3F(深森林绿)与 --red #B33A2A(暗砖红)替换为 Firecrawl 的 --accent-forest #42c366--accent-crimson #eb3424——更明亮、更接近真实"信号灯"颜色,但仍保持非荧光。

+
+
--green
#42c366 · 成功(=forest)
copied
+
--green-bg
8% · 配套底
copied
+
--green-bd
20% · 配套边
copied
+
--red
#eb3424 · 失败(=crimson)
copied
+
--red-bg
8% · 配套底
copied
+
--red-bd
20% · 配套边
copied
+
+
+ +
+

Selection 选中色 // Firecrawl 签名细节

+

页面内任何文字选中时,底色 --heat-20 + 字色 --heat。**试着选中下面这行字看效果:**

+

本月营收 ¥327,400 较上月增长 33%,有 5 个项目处于"生成中"状态。其中 2 个需要重新调整模特模板。

+
+
+ + + + +
+
+ // §2 · TYPE · V2.1 MIXED STRATEGY +

字体系统 · 中英协作

+

V2.1 改为**中英双字体协作** —— Inter 处理英文/数字,Alibaba PuHuiTi 处理中文,装饰用 JetBrains Mono。浏览器按字符级 fallthrough,Inter 不含 CJK 字形 → 中文自动跳到普惠体。**不需要 JS,中英自然分工。**

+
+ +
+

字体族原理 // browser-level character fallthrough

+
+
+
// 01 · ENGLISH
+
Inter
+
The quick brown fox jumps. 0123456789 — Vercel / Linear / Stripe 御用,屏幕 UI 优化,数字同宽。
+
+
+
// 02 · 中文
+
阿里普惠体
+
为中英混排专门设计的笔画粗细配比,与 Inter 视觉重量贴近,中文版面舒适匀质。
+
+
+
// 03 · 装饰 MONO
+
JetBrains
+
[ 200 OK ] · // 05.14 · /v2 — 仅用于装饰标签,不参与正文。
+
+
+
+
// 混排实测 · MIXED RENDERING
+
本月营收 ¥327,400 较上月 +33%,共 5 个 AI 项目处于 "生成中" 状态
+
↑ 这一行里:中文走 PuHuiTi · "¥" "327,400" "+33%" "5" "AI" 走 Inter · 字重视觉匀质,无错位
+
+
+ +
+

字号 / 字重 / 行高阶梯 // 11 档

+
+
H1 / Hero36 / 500 / -0.024em / 1.2
+
早上好,大莱
+
+
+
区块 H228 / 500 / -0.02em / 1.25
+
设计系统总览
+
+
+
KPI 数值32 / 500 / -0.02em / tabular-nums
+
¥327.40 K
+
+
+
子区 H316 / 500 / -0.01em / 1.4
+
最近项目
+
+
+
卡片标题14 / 500 / normal / 1.4
+
夏季新款蕾丝连衣裙 · 蓝色 / M
+
+
+
正文 body14 / 400 / normal / **1.65**
+
商家可能没有现成的商品图,需要新增一种「AI 生成」模式 —— 商家上传一张随手拍的原图,AI 生成 4 张满意的头图,选 1 张。
+
+
+
Label · 按钮 / Tab13 / 500 / normal
+
查看 Demo
+
+
+
描述次级13 / 400 / --ink-alpha-64 / 1.8
+
本月营收较上月增长 33%,5 个项目处于"生成中"状态,2 个需要重新调整模特模板
+
+
+
Pill 文字11.5 / 500 / normal
+
生成中
+
+
+
Inter Bold · 徽标11.5 / 700 / Inter only
+
Ctrl K · ESC · Enter · ⇧+Tab
+
+
+
Mono 标签11 / 400 / 0.04em · JetBrains
+
[ 200 OK ] [ .MP4 · 9:16 ] [ /v2 ]
+
+
+
Mono 散点装饰8.5 / 400 / 0.04em
+
· · + · +XX+ +XXXX· +X·
+
+
+ +
+

字重档位 // 仅 400 / 500 / 600 / 700

+
+
+
// REGULAR · 400
+
流·Studio · Aa Bb 123
+
+
+
// MEDIUM · 500 ★ 默认强调
+
流·Studio · Aa Bb 123
+
+
+
// SEMIBOLD · 600
+
流·Studio · Aa Bb 123
+
+
+
// BOLD · 700 · 限徽标
+
流·Studio · Aa Bb 123
+
+
+

// Bold 700 仅用于 Ctrl K 这种纯英文徽标场景,**正文严禁 700**(中英字重错位会暴露)

+
+
+ + + + +
+
+ // §3 · RADIUS +

圆角阶梯

+

V2 核心变化:统一 8 px 作为默认主圆角。完全圆 999 px 仅用于 pill 和 dot。微元素降到 4–6 px。

+
+ +
+
x
禁用 V1
0 px
+
progress
2 px
+
kbd / badge
4 px
+
小色块
6 px
+
主默认
8 px ★
+
pill
pill / dot
999 px
+
+
+ + + + +
+
+ // §4 · ICONS +

Icon 系统

+

统一 SVG line icon · stroke 1.5 · linecap round · 颜色通过 currentColor 继承。禁用 emoji / filled icon。

+
+ +
+

尺寸阶梯 // 5 档

+
+
+
+
S · 14 px
+
+
+
+
M · 16 px ★
+
+
+
+
L · 20 px
+
+
+
+
XL · 24 px
+
+
+
+
Hero · 36 px
+
+
+
+ +
+

颜色场景 // 通过 currentColor 继承

+
+
+ + 默认 ink-56 +
+
+ + hover · ink +
+
+ + active · heat +
+
+ + 在主 CTA 内 +
+
+ + disabled +
+
+
+ +
+

Icon Box // 快捷入口/Modal 头部用

+
+
+
+
+ 32×32 · 8 px 圆角 · --heat-12 底 · 16 px line icon +
+
+
+ + + + +
+
+ // §5 · BUTTONS +

按钮 · 3 类型 × 5 状态 × 3 尺寸

+

所有按钮:高 32 / 圆角 8 / 字号 13 / 字重 500。默认按钮用 ::before inside-border,hover 时边框淡出底色淡入,无布局抖动。

+
+ +
+

类型 1 · 默认 // .btn

+
// default
+
// hover
+
// active
+
// focused
+
// disabled
+
+ +
+

类型 2 · 主 CTA // .btn-primary

+

唯一允许阴影的按钮 —— 4 层橙色多重阴影,hover 时阴影向上抬起。

+
// default
+
// hover
+
// active
+
// focused
+
// disabled
+
+ +
+

类型 3 · 无框 // .btn-ghost

+
// default
+
// hover
+
// active
+
// disabled
+
+ +
+

尺寸 // -sm / 默认 / -lg

+
+ + + +
+
+ + + +
+
+
+ + + + +
+
+ // §6 · PILLS +

胶囊 · 严格 3 级分层

+

同级别尺寸必须完全一致。不允许混用。pill 永远是 999 px(完全圆),靠 dot 体现状态。

+
+ +
+

L1 · 大胶囊 // h:28 / fs:13 / dot:8

+

用于项目状态、列表行主标签。

+
+ 生成中 + 已完成 + 失败 +
+
+ +
+

L2 · 中胶囊 // h:22 / fs:11.5 / dot:6 ★ 默认

+

最常用尺寸。卡片内 / 表格内默认。

+
+ 生成中 + 200 OK + 超时 +
+
+ +
+

L3 · 小胶囊 // h:18 / fs:10.5 / dot:5

+

KPI 角标 / 行内 Mono 标签场景。

+
+ NEW + +33% + -1.2% +
+
+ +
+

对比展示 // 同色 3 级并列看大小差距

+
+ L1 · 生成中 + L2 · 生成中 + L3 · 生成中 +
+
+
+ + + + +
+
+ // §7 · INPUTS +

输入框 · 5 状态

+

同样用 inside-border。focused 时橙色 ring 2 px,error 时红色边框 + 红色软底。点击下方各字段可看到真实 focus 反馈。

+
+ +
+
// default
+
// hover
+
// focused
+
// error
+
// disabled
+
+ +
+

带图标 / 搜索 // 含 Ctrl+K 提示

+
+ + + Ctrl K +
+
+
+ + + + +
+
+ // §8 · FORM CONTROLS +

表单控件

+

Checkbox / Radio / Switch 全部可点击。disabled 状态也可演示。

+
+ +
+

Checkbox

+
+ + + + +
+
+ +
+

Radio

+
+ + + + +
+
+ +
+

Switch

+
+ + + +
+
+
+ + + + +
+
+ // §9 · TABS +

Tab · 双层结构

+

主 Tab 在区块顶部,带橙色下划线指示;副 Tab 用于过滤,带灰度→彩色 icon 反馈。点击切换。

+
+ +
+

主 Tab

+
+ + + + +
+
当前显示:全部
+
+ +
+

副 Tab · 灰度→彩色

+
+ +
+ +
+ +
+ +
+
+
+ + + + +
+
+ // §10 · CARDS +

卡片 / 快捷入口

+

所有卡片统一 8 px 圆角、1 px 边框、无阴影。快捷入口含 hover / active 状态。

+
+ + +
+ + + + +
+
+ // §11 · KPI +

统计行 · 4 格 stats

+
+ +
+
+
本月营收+33%
+
¥327.40 K
+
↑ 较上月 +33%
+
+
+
活跃项目
+
12
+
↑ 本月 +3
+
+
+
生成中RUNNING
+
5
+
+
+
+
资产总数
+
847
+
·MP4·JPG·PNG
+
+
+
+ + + + +
+
+ // §12 · LIST +

列表行

+

hover 我看整行底色变化。

+
+ +
+
+
9:16
+
夏季新款蕾丝连衣裙
// 创建于 05.14 · 蓝色 / M
+
+ 生成中 + +
+
+
4:5
+
秋季风衣 · 卡其色
// 创建于 05.12 · M / L
+
+ 已完成 + +
+
+
1:1
+
运动 T 恤 · 黑白款
// 创建于 05.10 · 全色系
+
+ 生成失败 + +
+
+
+ + + + +
+
+ // §13 · TIP +

提示框 / 进度

+
+ +
+
+
小提示
+
使用 Ctrl+K 快速搜索任意项目、商品或资产。Tab 切换不同维度,Enter 直达。
+
+
+ +
+

进度条段位 // 5 段 · 流水线专用

+
+
未开始
+
进行中(2/5)
+
已完成
+
失败
+
+
+
+ + + + + + + + + +
+
+ // §15 · TOAST +

Toast 通知

+

右下角浮出。300ms 弹性入场,2400ms 自动消失。

+
+ +
+ + +
+
+ + + + +
+
+ // §16 · SIGNATURE +

主容器装订线

+

整个工作区被左右两条 1 px 边线包夹,四角放圆弧内凹的 SVG 准星。这是流·Studio 视觉的"图纸"签名。Modal 内不必加。

+
+ +
+ + + + +
+ 这块容器左右贯穿两条 1 px 边线,四角带圆弧内凹的 SVG 准星
+ // container-demo · max-width: 720 · border-x only +
+
+
+ + + + +
+
+ // §17 · DECOR +

Mono 装饰元素 · 品牌签名

+

方括号标签 / 双斜杠注释 / 中点连接 —— 这些是流·Studio 独有的"调试视图感",Firecrawl 没有,绝对保留。

+
+ +
+

方括号标签

+
+ [ 200 OK ] + [ /v2 ] + [ .MP4 · 9:16 ] + [ STUDIO ] + [ ALL · 12 ] → +
+
+ +
+

注释样式时间戳

+
// 05.14 · 周五 · 14:32
+
+ +
+

命令路径

+
/sidebar collapse · /toast dismiss · /modal open
+
+ +
+

ASCII 散点(背景装饰)

+
+· ·  +
+·  +XX+
+ +XXXX·
+   +X· +
+
+ +
+

强调单词上色

+

+ 本月营收较上月增长 +33%,有 5 个项目处于"生成中"状态。其中 2 个需要重新调整模特模板。 +

+

// 关键名词加深一档(不变橙),橙色只留给 CTA

+
+
+ + + + +
+
+ // §18 · GUARDRAILS +

Don't List · 绝对禁止

+

任何 mockup / 代码 review 时,对照此清单。每一条违反都判错。

+
+ +
+
+ DO +
用 8 px 统一圆角 + 准星 + 装订线 + mono 装饰做"图纸感"
+
+
+ DON'T +
用 0 px 硬切角的卡片 —— V1 的做法,V2 起判错
+
+
+ DO +
橙色 hover 用 --heat-90 等 alpha 阶梯
+
+
+ DON'T +
hover 时切换到更深的橙 hex(如 #D04E1F)
+
+
+ DO +
主 CTA 用多层橙色阴影(4 层)制造发光感
+
+
+ DON'T +
用灰色阴影 / 文字阴影 / 通用 box-shadow 装饰
+
+
+ +
+
×渐变背景 —— 只有 hero 区可考虑,首选纯色。绝禁多色渐变。
+
×玻璃拟态 —— backdrop-filter 只用于 modal 遮罩。
+
×彩色 emoji —— 所有图标必须 SVG line(stroke 1.5)。
+
×多 accent 色 —— 全场只有橙色一个 accent。
+
×大圆角容器(>12 px)—— 直接判错。
+
×鲜艳荧光状态色 —— 避免霓虹绿、电光蓝、霓虹粉。
+
×居中对齐大段正文 —— 全部左对齐。
+
×装饰当主角 —— 场记板 / 丝绒 / 霓虹灯都不要。
+
×无意义微动效 —— hover 旋转、缩放、彩虹流光,禁。
+
×同行混用直角+圆角 —— 用户原话:"不要有些是直角,胶囊又是圆角"。
+
+
+ +
+
+ + + + + +
+ + + + diff --git a/core/frontend/public/exact/account.html b/core/frontend/public/exact/account.html new file mode 100644 index 0000000..066afe8 --- /dev/null +++ b/core/frontend/public/exact/account.html @@ -0,0 +1,881 @@ + + + + +消费 · Airshelf + + + + + +
+ +
+
+

消费

+
// 余额 · 充值 · 4 维消费视图 + 账单流水
+
+
+ + +
+ +
+ +
+
团队余额
+
¥327.40
+
// 充值累加 · 不重置
+
+
+
+
本月限额
+
¥3,000.00
+
// 按自然月重置
+
+
+
当月已用
+
¥162.60
+
// 占比 5.4% · 健康
+
+
+
+
+
+ 团队月剩余 ¥2,837.40 + 使用率 5.4% +
+
+
+ + +
+
+
+

快速充值

+
// 充值后立刻到账,可开发票 · 仅超管可操作
+
+
已选 ¥500
+
+
+
¥100
无赠送
+
推荐
¥500
+ ¥30 赠送
+
¥1000
+ ¥80 赠送
+
¥3000
+ ¥300 赠送
+
+
+
自定义金额
+ +
+ + +
+
+
+
+ + +
+ + + + +
+ + +
+
+
+
+

消费趋势

+ // 近 14 天 · 单位 ¥ + + + + +
+
+
+ +
+
+ +
+
+
+
14 天合计¥0.00
+
日均¥0.00
+
峰值¥0.00
+
+
+ +
+

本月按阶段分布

+
// PRD §5.3.5 扣费规则 · 仅确认后扣
+
视频片段(Seedance)¥98.40
+
+
故事板(image-2)¥36.00
+
+
基础资产¥21.00
+
+
脚本 LLM¥7.20
+
+
合计¥162.60
+
+
+ +
+

扣费 + 四层额度预检规则

+
// PRD §5.3.5 + §10.3 · 对接团队请以此页为准
+
+ ① 失败不扣:模型超时 / 内容审核拦截 / 生成异常一律不扣费。
+ ② 用户重跑不扣首次:第一次重跑保留原扣费,第二次起按次结算。
+ ③ 仅在你点击 [ 确认通过 ] 时入账
+ ④ 导出不再扣费,所有 token 已在过程中结算。 +
+
+
// 任务确认前 · 四层额度预检(任一不通过即拦截)
+
1个人日剩余 ≥ 任务预估 × 1.2
+
2个人月剩余 ≥ 同上
+
3团队月剩余 ≥ 同上
+
4团队总余额 ≥ 同上
+
+
+
+ + +
+
+ + + + + 0 个项目 · 消耗 ¥0.00 +
+ + + + + + + + + + + + + + +
项目商品所属成员当前阶段状态消耗
+ +
+ + +
+
+ + + + + 0 人 · 合计 ¥0.00 +
+ + + + + + + + + + + + + +
成员角色已完成项目已用 / 月度额度最近活跃
+ +
+ + +
+
+ + + + + + 0 +
+ + + + + + + + + + + + + + +
时间项目 / 类型详情成员状态金额
+ +
+ + + + +
+ + + + + + diff --git a/core/frontend/public/exact/asset-factory.html b/core/frontend/public/exact/asset-factory.html new file mode 100644 index 0000000..d0110cb --- /dev/null +++ b/core/frontend/public/exact/asset-factory.html @@ -0,0 +1,947 @@ + + + + +图片生成 · Airshelf + + + + + +
+ +
+
+

图片生成

+
+ // 一键生成 + · + 电商视觉素材,提升内容制作效率 +
+
+
+ + +
+ + +
+ + + +
+
+ [ MODEL · TRY-ON ] +
模特上身图
+
选择模特,AI 生成商品模特上身效果图
+ +
    +
  • + + + + 支持多模特选择 +
  • +
  • + + + + 一次生成 4 张 +
  • +
  • + + + + 支持多商品并行 +
  • +
+ +
+ + 开始生成 + + + [ ≈ ¥0.30 / 张 ] +
+
+ +
+
Ava · 9:16
+
+
变体 01
+
变体 02
+
变体 03
+
+
+
+
+ + +
+ + + +
+
+ [ PLATFORM · KIT ] +
平台套图
+
选择平台模板,AI 生成电商平台套图
+ +
    +
  • + + + + 覆盖主流电商平台 +
  • +
  • + + + + 一键生成 4 张套图 +
  • +
  • + + + + 智能排版设计 +
  • +
+ +
+ + 开始生成 + + + [ ≈ ¥0.50 / 张 ] +
+
+ +
+
套图 / TB
+
套图 / DY
+
套图 / XHS
+
套图 / PDD
+
+
+
+ + +
+ + + +
+
+ [ IMAGE · STUDIO ] +
图片创作
+
自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写
+ +
    +
  • + + + + 人物 · 商品 全支持 +
  • +
  • + + + + 正面 / 侧面 / 背面 一次输出 +
  • +
  • + + + + 多镜头一致性保证 +
  • +
+ +
+ + 开始生成 + + + [ ≈ ¥0.40 / 组 ] +
+
+ +
+
正 / 侧 / 背 · 三视图
+
+
+
+ +
+ + +
+

任务中心

+ // 0 个 · 0 生成中 · 0 已完成 · 0 失败 +
+ + +
+
全部 0
+
生成中 0
+
已完成 0
+
失败 0
+
+ + +
+
+ + +
+
+ +
+
全部时间
+
+
今天
+
1 小时内
+
10 分钟内
+
+
+
+ +
+
+ + +
+ + +
+
+ +
// 显示 0 / 0 个任务
+ + +
+ + + + + + + + + + + +
任务进度状态创建于
+
+ + + + + + + +
+ + + + + + + + + + + diff --git a/core/frontend/public/exact/assets/api-bridge.js b/core/frontend/public/exact/assets/api-bridge.js new file mode 100644 index 0000000..043bbfa --- /dev/null +++ b/core/frontend/public/exact/assets/api-bridge.js @@ -0,0 +1,3120 @@ +(function () { + "use strict"; + + const TOKEN_KEY = "airshelf_token"; + const LIVE_KEY = "airshelf_live"; + const USER_KEY = "airshelf_user"; + const TEAM_KEY = "airshelf_team"; + const BRIDGE_VERSION = "20260601-live-api-bridge-notifications"; + const CACHE_VERSION = "20260601"; + const CACHE_PREFIX = "airshelf:live-cache:"; + const requestMemo = {}; + const context = window.__AIR_SHELF_EXACT_CONTEXT__ || {}; + const sourceMeta = document.querySelector('meta[name="x-airshelf-exact-source"]'); + const page = (context.page || (sourceMeta && sourceMeta.content) || location.pathname.split("/").pop() || "index.html").toLowerCase(); + const query = new URLSearchParams(context.search || location.search); + const liveDataPages = { + "index.html": true, + "products.html": true, + "product-detail.html": true, + "projects.html": true, + "projects-new.html": true, + "pipeline.html": true, + "library.html": true, + "account.html": true, + "settings.html": true, + "team.html": true, + "messages.html": true, + }; + const needsLiveHydration = Boolean(context.liveHydrate || liveDataPages[page]); + + function token() { + return localStorage.getItem(TOKEN_KEY) || ""; + } + + function isLive() { + return query.get("live") === "1" || localStorage.getItem(LIVE_KEY) === "1"; + } + + function canUseApi() { + return !!token(); + } + + function cacheScope() { + try { + const team = JSON.parse(localStorage.getItem(TEAM_KEY) || "null"); + if (team?.id) return "team:" + team.id; + const user = JSON.parse(localStorage.getItem(USER_KEY) || "null"); + if (user?.id) return "user:" + user.id; + } catch (error) { + // Fall through to anonymous scope. + } + return "anonymous"; + } + + function cacheKey(name) { + return CACHE_PREFIX + CACHE_VERSION + ":" + cacheScope() + ":" + name; + } + + function readCache(name) { + try { + const raw = localStorage.getItem(cacheKey(name)); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && parsed.data !== undefined ? parsed.data : null; + } catch (error) { + return null; + } + } + + function writeCache(name, data) { + try { + localStorage.setItem(cacheKey(name), JSON.stringify({ ts: Date.now(), data: data })); + renderShellFromCache(); + } catch (error) { + // localStorage may be full or unavailable; live API rendering still works. + } + } + + function forgetCache(name) { + try { + localStorage.removeItem(cacheKey(name)); + delete requestMemo[name]; + renderShellFromCache(); + } catch (error) { + // Best effort only. + } + } + + function apiGet(cacheName, path) { + if (!requestMemo[cacheName]) { + requestMemo[cacheName] = api(path) + .then(function (data) { + writeCache(cacheName, data); + return data; + }) + .catch(function (error) { + delete requestMemo[cacheName]; + throw error; + }); + } + return requestMemo[cacheName]; + } + + function rememberAuthShape(meData) { + if (!meData) return; + try { + if (meData.user) localStorage.setItem(USER_KEY, JSON.stringify(meData.user)); + if (meData.team) localStorage.setItem(TEAM_KEY, JSON.stringify(meData.team)); + } catch (error) { + // Best effort only. + } + } + + function cachedAuthShape() { + const cached = readCache("auth:me"); + if (cached) return cached; + try { + const user = JSON.parse(localStorage.getItem(USER_KEY) || "null"); + const team = JSON.parse(localStorage.getItem(TEAM_KEY) || "null"); + return user || team ? { user: user || {}, team: team || {} } : null; + } catch (error) { + return null; + } + } + + async function loadWithCache(cacheName, fetcher, render, failTitle) { + const cached = readCache(cacheName); + if (cached) { + render(cached, true); + markHydrationDone(); + } + try { + const fresh = await fetcher(); + writeCache(cacheName, fresh); + render(fresh, false); + return fresh; + } catch (error) { + if (cached) { + toast(failTitle || "数据刷新失败", (error && error.message ? error.message : "接口暂不可用") + " · 已显示本地缓存"); + return cached; + } + toast(failTitle || "数据加载失败", error.message); + throw error; + } + } + + function markHydrationLoading() { + if (!needsLiveHydration || !canUseApi()) return; + document.documentElement.removeAttribute("data-live-error"); + document.documentElement.setAttribute("data-live-hydrating", "1"); + } + + function markHydrationDone() { + if (!needsLiveHydration) return; + document.documentElement.removeAttribute("data-live-hydrating"); + document.documentElement.removeAttribute("data-live-error"); + document.documentElement.setAttribute("data-live-ready", "1"); + } + + function markHydrationError(message) { + if (!needsLiveHydration) return; + document.documentElement.removeAttribute("data-live-hydrating"); + document.documentElement.setAttribute("data-live-error", "1"); + document.documentElement.dataset.liveErrorMessage = message || "真实数据加载失败"; + } + + function applyContextHash() { + const cleanHash = String(context.hash || location.hash || "").replace(/^#/, ""); + if (!cleanHash) return; + if (page === "settings.html" && cleanHash.indexOf("sec-") === 0 && typeof window.showSection === "function") { + window.showSection(cleanHash); + return; + } + const stageMatch = cleanHash.match(/^stage-(\d+)$/); + if (page === "pipeline.html" && stageMatch && typeof window.activateStage === "function") { + window.activateStage(Number(stageMatch[1])); + } + } + + function go(href) { + if (typeof window.__AIR_SHELF_HOST_NAVIGATE__ === "function") { + window.__AIR_SHELF_HOST_NAVIGATE__(href); + return; + } + location.href = href; + } + + function ready(fn) { + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); + else fn(); + } + + function esc(value) { + return String(value == null ? "" : value).replace(/[&<>"']/g, function (char) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]; + }); + } + + function dateOnly(value) { + if (!value) return new Date().toISOString().slice(0, 10); + return String(value).slice(0, 10); + } + + function shortLabel(value) { + const text = String(value || "商品").replace(/[·||]/g, " ").trim(); + return text.length > 9 ? text.slice(0, 9) : text; + } + + function parseError(text) { + try { + const data = JSON.parse(text); + if (typeof data.detail === "string") return data.detail; + if (Array.isArray(data.non_field_errors)) return data.non_field_errors.join(" / "); + const parts = []; + Object.keys(data).forEach(function (key) { + const value = data[key]; + if (Array.isArray(value)) parts.push(key + ": " + value.join(" / ")); + else if (typeof value === "string") parts.push(key + ": " + value); + }); + return parts.join(";") || text; + } catch (error) { + return text || "请求失败"; + } + } + + async function api(path, options) { + const opts = options || {}; + const headers = new Headers(opts.headers || {}); + if (!(opts.body instanceof FormData)) headers.set("Content-Type", "application/json"); + if (token()) headers.set("Authorization", "Token " + token()); + const response = await fetch(path, Object.assign({}, opts, { headers: headers })); + if (!response.ok) { + const text = await response.text(); + throw new Error(parseError(text)); + } + if (response.status === 204) return null; + return response.json(); + } + + function toast(title, sub) { + if (window.Shell && typeof window.Shell.toast === "function") { + window.Shell.toast(title, sub || ""); + return; + } + if (typeof window._loginToast === "function") { + window._loginToast(title, sub || ""); + return; + } + if (typeof window._regToast === "function") { + window._regToast(title, sub || ""); + return; + } + let el = document.getElementById("__api_bridge_toast"); + if (!el) { + el = document.createElement("div"); + el.id = "__api_bridge_toast"; + el.style.cssText = + "position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:380px;"; + document.body.appendChild(el); + } + el.innerHTML = + '
' + + esc(title) + + "
" + + (sub ? '
// ' + esc(sub) + "
" : ""); + requestAnimationFrame(function () { + el.style.opacity = "1"; + el.style.transform = "translateX(-50%) translateY(0)"; + }); + clearTimeout(el._timer); + el._timer = setTimeout(function () { + el.style.opacity = "0"; + el.style.transform = "translateX(-50%) translateY(20px)"; + }, 3000); + } + + function setAuthPayload(payload) { + localStorage.setItem(TOKEN_KEY, payload.token); + localStorage.setItem(LIVE_KEY, "1"); + localStorage.setItem(USER_KEY, JSON.stringify(payload.user || {})); + localStorage.setItem(TEAM_KEY, JSON.stringify(payload.team || {})); + } + + function renderShellIdentity(meData, billingData) { + if (meData) rememberAuthShape(meData); + const teamName = meData?.team?.name || "团队"; + const userName = meData?.user ? displayName(meData.user) : "成员"; + const email = meData?.user?.email || ""; + const avatar = initial(userName); + + document.querySelectorAll(".aside-foot .user .em, .shell-account-head .nm").forEach(function (el) { + el.textContent = teamName; + }); + document.querySelectorAll(".aside-foot .user .av, .topbar-avatar span, .shell-account-head .av").forEach(function (el) { + el.textContent = avatar; + }); + document.querySelectorAll(".shell-account-head .mail").forEach(function (el) { + el.textContent = email; + }); + syncShellBalance(billingData); + } + + function syncShellBalance(billingData) { + const balance = billingData?.account?.balance; + document.querySelectorAll(".balance-chip strong").forEach(function (el) { + el.textContent = balance === undefined || balance === null ? "--" : money(balance); + }); + } + + function firstPresent(values) { + for (let i = 0; i < values.length; i += 1) { + if (values[i] !== null && values[i] !== undefined) return values[i]; + } + return null; + } + + function collectionCount(data) { + if (!data) return null; + if (typeof data.count === "number") return data.count; + if (typeof data.count === "string" && data.count.trim() !== "" && !Number.isNaN(Number(data.count))) return Number(data.count); + if (Array.isArray(data.results)) return data.results.length; + if (Array.isArray(data)) return data.length; + return null; + } + + function listResults(data) { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.results)) return data.results; + return []; + } + + function unreadNotificationCount(data) { + if (!data) return null; + if (typeof data.unread_count === "number") return data.unread_count; + if (typeof data.unread_count === "string" && data.unread_count.trim() !== "" && !Number.isNaN(Number(data.unread_count))) { + return Number(data.unread_count); + } + const rows = listResults(data); + if (!rows.length && collectionCount(data) === 0) return 0; + return rows.filter(function (item) { + if (item.unread !== undefined) return Boolean(item.unread); + return item.is_read === false; + }).length; + } + + function emptyList() { + return { count: 0, next: null, previous: null, results: [] }; + } + + function listCacheOrEmpty(name) { + return readCache(name) || emptyList(); + } + + function cacheNested(name, key) { + const data = readCache(name); + return data && data[key] ? data[key] : null; + } + + function setShellBadge(href, count) { + document.querySelectorAll('aside.sidebar a[href="' + href + '"] .pill-mini').forEach(function (badge) { + if (count === null || count === undefined) { + badge.textContent = ""; + badge.hidden = true; + return; + } + badge.hidden = false; + badge.textContent = String(count); + }); + } + + function syncNotificationBadge() { + document.querySelectorAll(".count-noti").forEach(function (badge) { + const count = firstPresent([ + readCache("notifications:summary")?.unread_count, + unreadNotificationCount(readCache("notifications:list")), + ]); + if (count === null || count === undefined || Number(count) <= 0) { + badge.textContent = ""; + badge.hidden = true; + return; + } + badge.hidden = false; + badge.textContent = String(count); + }); + } + + function shellSummaryFromCache() { + return firstPresent([ + readCache("billing:summary"), + cacheNested("dashboard", "summary"), + cacheNested("account:bundle", "summary"), + cacheNested("team:bundle", "summary"), + cacheNested("settings:profile", "summary"), + ]); + } + + function shellProductDataFromCache() { + return firstPresent([ + readCache("products:list"), + cacheNested("dashboard", "products"), + cacheNested("account:bundle", "products"), + cacheNested("projects:bundle", "products"), + cacheNested("pipeline:latest", "products"), + ]); + } + + function shellProjectDataFromCache() { + return firstPresent([ + readCache("projects:list"), + cacheNested("dashboard", "projects"), + cacheNested("account:bundle", "projects"), + cacheNested("projects:bundle", "projects"), + ]); + } + + function renderShellFromCache() { + if (!document.body) return; + const meData = cachedAuthShape(); + const summaryData = shellSummaryFromCache(); + if (canUseApi() || meData || summaryData) renderShellIdentity(meData, summaryData); + setShellBadge("products.html", collectionCount(shellProductDataFromCache())); + setShellBadge("projects.html", collectionCount(shellProjectDataFromCache())); + syncNotificationBadge(); + } + + function seedProjectsBundleFromCache() { + if (readCache("projects:bundle")) return; + const products = shellProductDataFromCache(); + const projects = shellProjectDataFromCache(); + if (!products && !projects) return; + writeCache("projects:bundle", { + products: products || emptyList(), + projects: projects || emptyList(), + }); + } + + function seedAccountBundleFromCache() { + if (readCache("account:bundle")) return; + const summary = shellSummaryFromCache(); + const ledgers = readCache("billing:ledgers"); + const members = readCache("team:members"); + const products = shellProductDataFromCache(); + const projects = shellProjectDataFromCache(); + if (!summary && !ledgers && !members && !products && !projects) return; + writeCache("account:bundle", { + summary: summary || { account: { balance: 0 }, charged_total: 0 }, + ledgers: ledgers || [], + members: members || [], + products: products || emptyList(), + projects: projects || emptyList(), + }); + } + + function seedTeamBundleFromCache() { + if (readCache("team:bundle")) return; + const me = cachedAuthShape(); + const summary = readCache("billing:summary"); + const members = readCache("team:members"); + if (!me && !summary && !members) return; + writeCache("team:bundle", { + me: me || {}, + summary: summary || { account: { balance: 0 }, charged_total: 0 }, + members: members || [], + }); + } + + async function hydrateShellIdentity() { + if (!canUseApi()) return; + const cachedMe = cachedAuthShape(); + const cachedBilling = readCache("billing:summary"); + renderShellFromCache(); + if (cachedMe) renderShellIdentity(cachedMe, cachedBilling); + try { + const meData = await apiGet("auth:me", "/api/auth/me/"); + writeCache("auth:me", meData); + const billingData = await apiGet("billing:summary", "/api/billing/summary/").catch(function () { + return null; + }); + if (billingData) writeCache("billing:summary", billingData); + renderShellIdentity(meData, billingData || cachedBilling); + renderShellFromCache(); + } catch (error) { + // Shell identity is a convenience layer; page-specific API errors are handled below. + } + } + + function setButtonBusy(button, text) { + if (!button) return function () {}; + const html = button.innerHTML; + button.disabled = true; + button.innerHTML = + '// ' + + esc(text) + + ""; + return function () { + button.disabled = false; + button.innerHTML = html; + }; + } + + function wireAuth() { + if (page === "login.html") { + window.doLogin = async function () { + const email = document.getElementById("auth-email").value.trim(); + const password = document.getElementById("auth-pwd").value; + const restore = setButtonBusy(document.querySelector(".btn-cta"), "验证中..."); + try { + const payload = await api("/api/auth/login/", { + method: "POST", + body: JSON.stringify({ username: email, password: password }), + }); + setAuthPayload(payload); + toast("登录成功", "已接入 Django 真实会话"); + go("index.html"); + } catch (error) { + restore(); + toast("登录失败", error.message || "请检查账号密码"); + } + }; + } + + if (page === "register.html") { + window.doRegister = async function () { + const team = document.getElementById("reg-team").value.trim(); + const email = document.getElementById("reg-email").value.trim(); + const password = document.getElementById("reg-pwd").value; + const confirm = document.getElementById("reg-pwd2").value; + const agree = document.getElementById("reg-agree").checked; + if (!team || !email) return alert("请补全团队名 + 邮箱"); + if (password.length < 8) return alert("密码至少 8 位"); + if (password !== confirm) return alert("两次密码不一致"); + if (!agree) return alert("请同意用户协议"); + + const restore = setButtonBusy(document.getElementById("reg-submit"), "创建团队中..."); + try { + const payload = await api("/api/auth/register/", { + method: "POST", + body: JSON.stringify({ + username: email, + email: email, + password: password, + team_name: team, + }), + }); + setAuthPayload(payload); + toast("注册成功", "团队与试用额度已创建"); + go("index.html"); + } catch (error) { + restore(); + toast("注册失败", error.message || "请稍后重试"); + } + }; + } + } + + function sellingPointsFromDrawer() { + const points = Array.from(document.querySelectorAll("#pf-bullets .bl-item .bl-text")) + .map(function (el) { + return el.textContent.trim(); + }) + .filter(Boolean); + const pending = document.querySelector("#pf-bullets .bl-add .bl-input"); + if (pending && pending.value.trim()) points.push(pending.value.trim()); + return points; + } + + let productCreateInFlight = false; + + async function submitLiveProduct(saveBtn) { + if (!canUseApi()) return false; + if (productCreateInFlight) return true; + + const nameEl = document.getElementById("pf-name"); + const catEl = document.getElementById("pf-cat"); + const targetEl = document.getElementById("pf-target"); + const name = (nameEl && nameEl.value ? nameEl.value : "").trim(); + const category = catEl ? catEl.value : ""; + const target = targetEl ? targetEl.value.trim() : ""; + const points = sellingPointsFromDrawer(); + + if (!name) { + toast("请填写商品名称", "必填项"); + if (nameEl) nameEl.focus(); + return true; + } + if (points.length === 0) { + toast("请填写核心卖点", "至少 1 条"); + return true; + } + + productCreateInFlight = true; + const restore = setButtonBusy(saveBtn, "保存商品中..."); + try { + const product = await api("/api/products/", { + method: "POST", + body: JSON.stringify({ + title: name, + category: category, + target_audience: target, + selling_points: points.map(function (title, index) { + return { title: title, detail: "", sort_order: index }; + }), + }), + }); + rememberPrototypeProduct(product, { + title: name, + category: category, + target: target, + points: points, + }); + forgetCache("products:list"); + forgetCache("projects:bundle"); + forgetCache("dashboard"); + localStorage.setItem("airshelf_current_product_id", product.id); + toast("商品已创建", "已写入 Django 数据库"); + go("product-detail.html?product_id=" + encodeURIComponent(product.id) + "&product=" + encodeURIComponent(product.title || name)); + } catch (error) { + productCreateInFlight = false; + restore(); + toast("创建失败", error.message); + } + return true; + } + + function rememberPrototypeProduct(product, draft) { + try { + const key = "fs-extra-products"; + const list = JSON.parse(sessionStorage.getItem(key) || "[]"); + list.push({ + id: product.id, + name: product.title || draft.title, + cat: product.category || draft.category || "未分类", + target: product.target_audience || draft.target || "", + assets: 0, + videos: 0, + bullets: draft.points || [], + date: dateOnly(product.created_at), + createdAt: Date.now(), + }); + sessionStorage.setItem(key, JSON.stringify(list)); + } catch (error) { + // Prototype compatibility only; API persistence is already complete. + } + } + + function productCardHTML(product, index) { + const title = product.title || "未命名商品"; + const cat = product.category || "未分类"; + const date = dateOnly(product.created_at); + const assets = (product.images && product.images.length) || 0; + const videos = product.metadata && product.metadata.videos_count ? product.metadata.videos_count : 0; + return ( + '
' + + '' + + '' + + '
' + + esc(shortLabel(title)) + + " · 1200×800
" + + '
' + + esc(title) + + '
' + + esc(cat) + + '
' + + esc(date) + + " 创建
" + + '
" + ); + } + + function syncProductCount() { + const cards = Array.from(document.querySelectorAll("#product-grid .product-card")); + const visible = cards.filter(function (card) { + return card.style.display !== "none"; + }).length; + const total = cards.length; + const sku = document.getElementById("sku-count"); + if (sku) sku.textContent = String(total); + const meta = document.getElementById("result-meta"); + if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + total + " 个商品"; + setShellBadge("products.html", total); + const empty = document.getElementById("empty"); + if (empty) empty.hidden = visible !== 0; + } + + function applyLiveProductFilter() { + const q = (document.getElementById("search-input") || {}).value || ""; + const needle = q.trim().toLowerCase(); + document.querySelectorAll("#product-grid .product-card").forEach(function (card) { + const hay = ((card.dataset.name || "") + " " + (card.dataset.cat || "") + " " + (card.dataset.tags || "")).toLowerCase(); + card.style.display = !needle || hay.indexOf(needle) >= 0 ? "" : "none"; + }); + syncProductCount(); + } + + function bindLiveProductCards() { + const grid = document.getElementById("product-grid"); + if (!grid) return; + grid.querySelectorAll(".product-card").forEach(function (card) { + card.addEventListener("click", function (event) { + if (document.body.classList.contains("edit-mode")) { + event.preventDefault(); + event.stopImmediatePropagation(); + card.classList.toggle("selected"); + return; + } + const id = card.dataset.productId; + const name = card.dataset.name || ""; + if (id) go("product-detail.html?product_id=" + encodeURIComponent(id) + "&product=" + encodeURIComponent(name)); + }); + }); + + grid.querySelectorAll('[data-action="delete-product"][data-product-id]').forEach(function (button) { + button.addEventListener("click", async function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + const card = button.closest(".product-card"); + const id = button.dataset.productId; + if (!id || !confirm("确认删除商品「" + (card && card.dataset.name ? card.dataset.name : "") + "」?")) return; + try { + await api("/api/products/" + encodeURIComponent(id) + "/", { method: "DELETE" }); + if (card) card.remove(); + forgetCache("products:list"); + forgetCache("projects:bundle"); + forgetCache("dashboard"); + syncProductCount(); + toast("已删除", "商品已从 Django 数据库移除"); + } catch (error) { + toast("删除失败", error.message); + } + }); + }); + } + + function renderLiveProductsPayload(data) { + const grid = document.getElementById("product-grid"); + if (!grid) return; + const products = data.results || []; + grid.innerHTML = products.length + ? products.map(productCardHTML).join("") + : '
// 当前团队还没有真实商品
'; + bindLiveProductCards(); + applyLiveProductFilter(); + } + + async function loadLiveProducts() { + if (!canUseApi()) return; + const grid = document.getElementById("product-grid"); + if (!grid) return; + return loadWithCache( + "products:list", + function () { + return apiGet("products:list", "/api/products/"); + }, + renderLiveProductsPayload, + "商品加载失败" + ); + } + + function wireProductCreate() { + const saveBtn = document.getElementById("pc-save-btn"); + if (!saveBtn) return; + + const submitHandler = function (event) { + const button = event.target && event.target.closest ? event.target.closest("#pc-save-btn") : null; + if (!button || !canUseApi()) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + submitLiveProduct(button); + }; + + document.addEventListener("click", submitHandler, true); + saveBtn.addEventListener("click", submitHandler, true); + + const search = document.getElementById("search-input"); + if (search) { + search.addEventListener( + "input", + function (event) { + if (!canUseApi()) return; + event.stopImmediatePropagation(); + applyLiveProductFilter(); + }, + true + ); + } + } + + function renderProductDetail(product) { + if (!product) return; + const title = product.title || ""; + const category = product.category || ""; + const target = product.target_audience || ""; + const h1 = document.getElementById("pd-name"); + if (h1 && title) h1.textContent = title; + setField("name", title); + setField("cat", category); + setField("target", target); + const bulletBox = document.querySelector('[data-field="bullets"] .v-static'); + if (bulletBox && product.selling_points && product.selling_points.length) { + bulletBox.innerHTML = product.selling_points + .map(function (point) { + return '' + esc(point.title) + ""; + }) + .join(""); + } + } + + async function hydrateProductDetail() { + if (!canUseApi()) return; + if (page !== "product-detail.html") return; + const id = query.get("product_id") || localStorage.getItem("airshelf_current_product_id"); + if (!id) return; + return loadWithCache( + "product:" + id, + function () { + return apiGet("product:" + id, "/api/products/" + encodeURIComponent(id) + "/"); + }, + renderProductDetail, + "商品详情加载失败" + ); + } + + function setField(field, value) { + const row = document.querySelector('[data-field="' + field + '"]'); + if (!row) return; + const stat = row.querySelector(".v-static"); + const input = row.querySelector(".v-input, .v-select"); + if (stat && value) stat.textContent = value; + if (input && value) { + if (input.tagName === "SELECT" && !Array.from(input.options).some(function (option) { return option.value === value; })) { + const option = document.createElement("option"); + option.textContent = value; + option.value = value; + input.insertBefore(option, input.firstChild); + } + input.value = value; + } + } + + function stageNo(project) { + const map = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 }; + if (project.status === "completed") return 5; + return map[project.current_stage] || 1; + } + + function statusBucket(project) { + if (project.status === "completed") return "done"; + if (project.status === "failed") return "fail"; + return "wip"; + } + + function projectStatusLabel(project) { + return { + draft: "脚本待生成", + scripting: "脚本生成中", + asseting: "基础资产生成中", + storyboarding: "故事板生成中", + videoing: "视频片段生成中", + exporting: "导出中", + completed: "已完成", + failed: "失败", + }[project.status] || "进行中"; + } + + function pillClass(project) { + if (project.status === "completed") return "ok"; + if (project.status === "failed") return "fail"; + return "info"; + } + + function progressHTML(project) { + const no = stageNo(project); + let html = ""; + for (let i = 1; i <= 5; i += 1) { + let cls = ""; + if (project.status === "completed" || i < no) cls = "done"; + else if (i === no) cls = "cur"; + html += ''; + } + return html; + } + + function projectHref(project) { + return "pipeline.html?project_id=" + encodeURIComponent(project.id) + "#stage-" + stageNo(project); + } + + function liveProjectRowHTML(project, productName) { + const href = projectHref(project); + const status = statusBucket(project); + const label = projectStatusLabel(project); + const shots = (project.video_segments && project.video_segments.length) || 4; + const no = stageNo(project); + return ( + '' + + '
9:16
' + + esc(project.name) + + '
' + + esc(shots) + + " 镜 · 0-60s
" + + "" + + esc(productName || "未命名商品") + + 'AI 全生
' + + progressHTML(project) + + '
' + + esc(no) + + '/5
' + + esc(label) + + '' + + esc(dateOnly(project.updated_at)) + + '
' + ); + } + + function liveProjectCardHTML(project, productName) { + const href = projectHref(project); + const status = statusBucket(project); + const label = projectStatusLabel(project); + const shots = (project.video_segments && project.video_segments.length) || 4; + const no = stageNo(project); + return ( + '
' + + '' + + '' + + '
9:16 · 阶段 ' + + esc(no) + + '/5
' + + esc(project.name) + + '
' + + esc(productName || "未命名商品") + + " · " + + esc(shots) + + ' 镜
' + + progressHTML(project) + + '
' + + esc(no) + + '/5
' + + esc(label) + + '' + + esc(dateOnly(project.updated_at)) + + "
" + ); + } + + function syncProjectCount() { + const rows = Array.from(document.querySelectorAll("#list-tbody tr")); + const visible = rows.filter(function (row) { + return row.style.display !== "none"; + }).length; + const counts = { total: rows.length, wip: 0, done: 0, fail: 0 }; + rows.forEach(function (row) { + const s = row.dataset.status; + if (counts[s] !== undefined) counts[s] += 1; + }); + const total = document.getElementById("sub-total"); + const wip = document.getElementById("sub-wip"); + const done = document.getElementById("sub-done"); + const fail = document.getElementById("sub-fail"); + if (total) total.textContent = counts.total; + if (wip) wip.textContent = counts.wip; + if (done) done.textContent = counts.done; + if (fail) fail.textContent = counts.fail; + document.querySelectorAll("#status-tabs .tab").forEach(function (tab) { + const filter = tab.dataset.filter; + const n = filter === "all" ? counts.total : counts[filter] || 0; + const el = tab.querySelector(".count"); + if (el) el.textContent = n; + }); + const meta = document.getElementById("result-meta"); + if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + counts.total + " 个项目"; + setShellBadge("projects.html", counts.total); + const empty = document.getElementById("empty"); + if (empty) empty.hidden = visible !== 0; + } + + function applyLiveProjectFilter() { + const active = document.querySelector("#status-tabs .tab.active"); + const filter = active ? active.dataset.filter : "all"; + const needle = ((document.getElementById("search-input") || {}).value || "").trim().toLowerCase(); + const test = function (el) { + const statusOk = filter === "all" || el.dataset.status === filter; + const textOk = !needle || (el.dataset.name || "").toLowerCase().indexOf(needle) >= 0; + return statusOk && textOk; + }; + document.querySelectorAll("#list-tbody tr, #grid-body .proj-card").forEach(function (el) { + el.style.display = test(el) ? "" : "none"; + }); + syncProjectCount(); + } + + function bindLiveProjects() { + document.querySelectorAll("#list-tbody tr[data-project-id], #grid-body .proj-card[data-project-id]").forEach(function (el) { + el.addEventListener("click", function (event) { + if (event.target.closest("button, a, .row-more")) return; + if (document.body.classList.contains("edit-mode")) { + event.preventDefault(); + event.stopImmediatePropagation(); + el.classList.toggle("selected"); + return; + } + go(el.dataset.href || projectHref({ id: el.dataset.projectId, current_stage: "script", status: "" })); + }); + }); + + document.querySelectorAll('[data-action="delete-project"][data-project-id]').forEach(function (button) { + button.addEventListener("click", async function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + const id = button.dataset.projectId; + const el = button.closest(".proj-card, tr"); + const name = el ? el.dataset.name : ""; + if (!id || !confirm("确认删除项目「" + name + "」?")) return; + try { + await api("/api/projects/" + encodeURIComponent(id) + "/", { method: "DELETE" }); + document.querySelectorAll('[data-project-id="' + CSS.escape(id) + '"]').forEach(function (node) { + node.remove(); + }); + forgetCache("projects:bundle"); + forgetCache("projects:list"); + forgetCache("dashboard"); + applyLiveProjectFilter(); + toast("已删除", "项目已从 Django 数据库移除"); + } catch (error) { + toast("删除失败", error.message); + } + }); + }); + + const search = document.getElementById("search-input"); + if (search) { + search.addEventListener( + "input", + function (event) { + if (!canUseApi()) return; + event.stopImmediatePropagation(); + applyLiveProjectFilter(); + }, + true + ); + } + document.querySelectorAll("#status-tabs .tab").forEach(function (tab) { + tab.addEventListener( + "click", + function (event) { + if (!canUseApi()) return; + event.preventDefault(); + event.stopImmediatePropagation(); + document.querySelectorAll("#status-tabs .tab").forEach(function (x) { + x.classList.remove("active"); + }); + tab.classList.add("active"); + applyLiveProjectFilter(); + }, + true + ); + }); + } + + function renderLiveProjectsPayload(payload) { + const tbody = document.getElementById("list-tbody"); + const grid = document.getElementById("grid-body"); + if (!tbody || !grid) return; + const productData = payload.products || {}; + const projectData = payload.projects || {}; + const productMap = {}; + (productData.results || []).forEach(function (product) { + productMap[product.id] = product.title; + }); + const projects = projectData.results || []; + tbody.innerHTML = projects.length + ? projects + .map(function (project) { + return liveProjectRowHTML(project, productMap[project.product]); + }) + .join("") + : '// 当前团队还没有真实项目'; + grid.innerHTML = projects.length + ? projects + .map(function (project) { + return liveProjectCardHTML(project, productMap[project.product]); + }) + .join("") + : '
// 当前团队还没有真实项目
'; + bindLiveProjects(); + applyLiveProjectFilter(); + } + + async function loadLiveProjects() { + if (!canUseApi()) return; + const tbody = document.getElementById("list-tbody"); + const grid = document.getElementById("grid-body"); + if (!tbody || !grid) return; + seedProjectsBundleFromCache(); + return loadWithCache( + "projects:bundle", + async function () { + const productData = await apiGet("products:list", "/api/products/"); + writeCache("products:list", productData); + const projectData = await apiGet("projects:list", "/api/projects/"); + return { products: productData, projects: projectData }; + }, + renderLiveProjectsPayload, + "项目加载失败" + ); + } + + async function ensureProduct(title, category, id) { + if (id) return api("/api/products/" + encodeURIComponent(id) + "/"); + const data = await api("/api/products/?search=" + encodeURIComponent(title)); + const found = (data.results || []).find(function (product) { + return product.title === title; + }); + if (found) return found; + return api("/api/products/", { + method: "POST", + body: JSON.stringify({ title: title, category: category || "" }), + }); + } + + function selectedWizardProduct() { + const card = document.querySelector("#step-pane-1 .product-card.selected, #step-pane-1 .product-pick.selected"); + if (!card) return null; + return { + id: card.dataset.productId || card.dataset.id || "", + title: (card.querySelector(".product-name, .name") || {}).textContent || "", + category: (card.querySelector(".product-cat, .meta") || {}).textContent || "", + }; + } + + function projectNameFromWizard(productTitle) { + const inputs = Array.from(document.querySelectorAll("#step-pane-2 input.input")); + const named = inputs.find(function (input) { + return input.value && input.value.trim().length >= 2; + }); + const value = named ? named.value.trim() : ""; + return value || (productTitle ? productTitle + " · AI 视频" : "未命名项目"); + } + + function liveWizardProductHTML(product, index) { + const title = product.title || "未命名商品"; + const category = product.category || "未分类"; + const date = dateOnly(product.created_at); + return ( + '
' + + '
' + + esc(shortLabel(title)) + + " · 1200×800
" + + '
' + + esc(title) + + '
' + + esc(category) + + '
' + + esc(date) + + " 创建
" + ); + } + + function renderLiveWizardProductsPayload(data) { + const grid = document.querySelector("#step-pane-1 .pp-grid"); + if (!grid) return; + const products = data.results || []; + const createCard = + '
新建商品
// 进入商品库创建
'; + grid.innerHTML = + createCard + + (products.length + ? products.map(liveWizardProductHTML).join("") + : '
// 当前团队还没有商品 · 请先新建商品
'); + grid.querySelector('[data-live-create="1"]')?.addEventListener("click", function () { + go("products.html"); + }); + grid.querySelectorAll(".product-card[data-product-id]").forEach(function (card) { + card.addEventListener("click", function () { + grid.querySelectorAll(".product-card").forEach(function (node) { + node.classList.remove("selected"); + }); + card.classList.add("selected"); + const title = (card.querySelector(".product-name") || {}).textContent || ""; + const projectInputs = Array.from(document.querySelectorAll("#step-pane-2 input.input")); + const target = projectInputs.find(function (input) { + return input.placeholder && input.placeholder.indexOf("项目") >= 0; + }); + if (target && !target.value.trim()) target.value = title + " · 痛点种草 · v1"; + }); + }); + const meta = document.querySelector("#step-pane-1 .pp-result-meta"); + if (meta) meta.textContent = "// 显示 " + products.length + " / " + products.length + " 个真实商品"; + document.querySelectorAll(".btn-start.disabled").forEach(function (button) { + button.classList.remove("disabled"); + }); + } + + async function loadLiveWizardProducts() { + if (page !== "projects-new.html" || !canUseApi()) return; + const grid = document.querySelector("#step-pane-1 .pp-grid"); + if (!grid) return; + return loadWithCache( + "products:list", + function () { + return apiGet("products:list", "/api/products/"); + }, + renderLiveWizardProductsPayload, + "商品加载失败" + ); + } + + function wireProjectWizard() { + if (page !== "projects-new.html") return; + if (!window._wiz || typeof window._wiz.startGenerate !== "function") return; + const original = window._wiz.startGenerate; + window._wiz.startGenerate = async function () { + if (!canUseApi()) return original(); + const picked = selectedWizardProduct(); + if (!picked || !picked.title.trim()) { + toast("请选择商品", "项目必须绑定一个商品"); + return; + } + const startBtn = document.querySelector(".btn-start"); + const restore = setButtonBusy(startBtn, "创建项目中..."); + try { + const product = await ensureProduct(picked.title.trim(), picked.category.trim(), picked.id); + const project = await api("/api/projects/", { + method: "POST", + body: JSON.stringify({ + name: projectNameFromWizard(product.title), + product: product.id, + }), + }); + forgetCache("projects:bundle"); + forgetCache("projects:list"); + forgetCache("dashboard"); + localStorage.setItem("airshelf_current_project_id", project.id); + toast("项目已创建", "已写入 Django,进入生产管线"); + go( + "pipeline.html?project_id=" + + encodeURIComponent(project.id) + + "&product=" + + encodeURIComponent(product.title || picked.title) + + "#stage-1" + ); + } catch (error) { + restore(); + toast("创建项目失败", error.message); + } + }; + } + + function money(value) { + const n = Number(value || 0); + return "¥" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + + function plainMoney(value) { + const n = Number(value || 0); + return "¥" + n.toFixed(2); + } + + function roleUi(role) { + if (role === "owner" || role === "super") return { key: "super", label: "超管" }; + if (role === "admin") return { key: "admin", label: "团管" }; + if (role === "viewer") return { key: "member", label: "访客" }; + return { key: "member", label: "成员" }; + } + + function displayName(user) { + const raw = String(user?.username || user?.email || "成员").trim(); + if (raw.indexOf("@") > 0) return raw.split("@")[0]; + return raw || "成员"; + } + + function userPublicId(user) { + const id = String(user?.id || "").replace(/-/g, "").toUpperCase(); + return id ? "USR-" + id.slice(0, 12) : "USR-UNKNOWN"; + } + + function initial(name) { + return String(name || "U").trim().slice(0, 1).toUpperCase() || "U"; + } + + function stageLabel(project) { + return { + script: "Stage 1 脚本", + base_assets: "Stage 2 基础资产", + storyboard: "Stage 3 故事板", + video: "Stage 4 视频", + export: "Stage 5 导出", + }[project.current_stage] || "Stage 1 脚本"; + } + + function assetTab(asset) { + const category = asset.category || ""; + if (category === "person") return "people"; + if (category === "scene") return "scenes"; + if (category === "product_image") return "products"; + if (category === "final_video" || category === "video_clip") return "finals"; + if (category === "upload") return "uploads"; + return "unclassified"; + } + + function assetMeta(asset, tab) { + const source = asset.source === "ai_generated" ? "AI 生成" : asset.source === "exported" ? "导出" : "手动上传"; + const type = asset.asset_type === "video" ? "视频" : "图片"; + if (tab === "people") return "人物 · " + source + " · 用过 0 次"; + if (tab === "scenes") return "场景 · " + source + " · 用过 0 次"; + if (tab === "products") return "商品图 · " + source + " · 用过 0 次"; + if (tab === "finals") return "成片 · " + type + " · " + dateOnly(asset.created_at); + return type + " · " + source + " · " + dateOnly(asset.created_at); + } + + function liveAssetCardHTML(asset, tab) { + const file = asset.files && asset.files[0]; + const isVideo = asset.asset_type === "video"; + const preview = file && file.preview_url; + const thumb = preview + ? isVideo + ? '' + : '' + : '' + esc(shortLabel(asset.name)) + ""; + return ( + '
' + + '' + + '
' + + thumb + + '
' + + esc(asset.name || "未命名资产") + + '
' + + esc(assetMeta(asset, tab)) + + "
" + ); + } + + function applyLiveAssetTab(tab) { + const assets = window.__airshelfLiveAssets || []; + document.querySelectorAll("#asset-tabs .tab").forEach(function (node) { + node.classList.toggle("active", node.dataset.tab === tab); + }); + document.querySelectorAll(".asset-grid[data-tab]").forEach(function (grid) { + grid.hidden = grid.dataset.tab !== tab; + }); + const grid = document.getElementById("grid-" + tab); + const queryText = ((document.getElementById("search-input") || {}).value || "").trim().toLowerCase(); + let visible = 0; + if (grid) { + grid.querySelectorAll(".asset-card").forEach(function (card) { + const ok = !queryText || (card.dataset.name || "").toLowerCase().indexOf(queryText) >= 0; + card.style.display = ok ? "" : "none"; + if (ok) visible += 1; + }); + } + const total = assets.filter(function (asset) { + return assetTab(asset) === tab; + }).length; + const meta = document.getElementById("result-meta"); + if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + total + " 个资产"; + } + + function renderLiveAssetsPayload(data) { + const assets = data.results || []; + window.__airshelfLiveAssets = assets; + const tabs = ["people", "scenes", "products", "finals", "uploads", "unclassified"]; + tabs.forEach(function (tab) { + const grid = document.getElementById("grid-" + tab); + if (!grid) return; + const list = assets.filter(function (asset) { + return assetTab(asset) === tab; + }); + grid.innerHTML = list.length + ? list + .map(function (asset) { + return liveAssetCardHTML(asset, tab); + }) + .join("") + : '
// 当前分类暂无真实资产
'; + const count = document.querySelector('#asset-tabs .tab[data-tab="' + tab + '"] .count'); + if (count) count.textContent = String(list.length); + }); + const counts = tabs.reduce(function (acc, tab) { + acc[tab] = assets.filter(function (asset) { + return assetTab(asset) === tab; + }).length; + return acc; + }, {}); + const people = document.getElementById("sub-people"); + const scenes = document.getElementById("sub-scenes"); + const products = document.getElementById("sub-products"); + const finals = document.getElementById("sub-finals"); + if (people) people.textContent = String(counts.people || 0); + if (scenes) scenes.textContent = String(counts.scenes || 0); + if (products) products.textContent = String(counts.products || 0); + if (finals) finals.textContent = String(counts.finals || 0); + const active = document.querySelector("#asset-tabs .tab.active"); + applyLiveAssetTab(active ? active.dataset.tab : "people"); + } + + async function loadLiveAssets() { + if (page !== "library.html" || !canUseApi()) return; + return loadWithCache( + "assets:list", + function () { + return apiGet("assets:list", "/api/assets/"); + }, + renderLiveAssetsPayload, + "资产加载失败" + ); + } + + let assetUploadInFlight = false; + + async function submitLiveAsset(submit) { + if (!canUseApi()) return false; + if (assetUploadInFlight) return true; + + const file = document.getElementById("upload-file")?.files?.[0]; + const name = (document.getElementById("upload-name")?.value || "").trim(); + const kind = document.getElementById("upload-kind")?.value || "uploads"; + if (!file) { + toast("请选择文件", "资产上传需要真实文件"); + return true; + } + if (!name) { + toast("请填写资产名称", "必填项"); + return true; + } + const categoryMap = { + people: "person", + scenes: "scene", + products: "product_image", + finals: "final_video", + uploads: "upload", + unclassified: "uncategorized", + }; + const form = new FormData(); + form.append("file", file); + form.append("name", name); + form.append("asset_type", kind === "finals" ? "video" : "image"); + form.append("category", categoryMap[kind] || "upload"); + assetUploadInFlight = true; + const restore = setButtonBusy(submit, "上传中..."); + try { + await api("/api/assets/upload/", { method: "POST", body: form }); + forgetCache("assets:list"); + forgetCache("dashboard"); + if (window.Shell) Shell.closeModal("upload-modal-bg"); + toast("资产已上传", "已写入 TOS 与资产表"); + await loadLiveAssets(); + } catch (error) { + toast("上传失败", error.message); + } finally { + assetUploadInFlight = false; + restore(); + } + return true; + } + + function wireLiveAssets() { + if (page !== "library.html") return; + document.querySelectorAll("#asset-tabs .tab").forEach(function (tab) { + tab.addEventListener( + "click", + function (event) { + if (!canUseApi()) return; + event.preventDefault(); + event.stopImmediatePropagation(); + applyLiveAssetTab(tab.dataset.tab); + }, + true + ); + }); + const search = document.getElementById("search-input"); + if (search) { + search.addEventListener( + "input", + function (event) { + if (!canUseApi()) return; + event.stopImmediatePropagation(); + const active = document.querySelector("#asset-tabs .tab.active"); + applyLiveAssetTab(active ? active.dataset.tab : "people"); + }, + true + ); + } + const submit = document.getElementById("upload-submit"); + if (!submit) return; + const submitHandler = function (event) { + const button = event.target && event.target.closest ? event.target.closest("#upload-submit") : null; + if (!button || !canUseApi()) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + submitLiveAsset(button); + }; + document.addEventListener("click", submitHandler, true); + submit.addEventListener("click", submitHandler, true); + } + + function ledgerStageKey(ledger) { + const text = String(ledger.reason || ledger.ledger_type || ledger.metadata?.stage || ledger.metadata?.task_type || "").toLowerCase(); + if (text.indexOf("video") >= 0 || text.indexOf("seedance") >= 0 || text.indexOf("视频") >= 0) return "video"; + if (text.indexOf("story") >= 0 || text.indexOf("image-2") >= 0 || text.indexOf("故事板") >= 0) return "storyboard"; + if (text.indexOf("asset") >= 0 || text.indexOf("image") >= 0 || text.indexOf("基础资产") >= 0) return "asset"; + if (text.indexOf("script") >= 0 || text.indexOf("llm") >= 0 || text.indexOf("脚本") >= 0) return "script"; + return "other"; + } + + function renderLiveAccountOverview(ledgers, used) { + const spendByStage = { video: 0, storyboard: 0, asset: 0, script: 0 }; + const daily = {}; + ledgers.forEach(function (ledger) { + const amount = Math.abs(Math.min(0, Number(ledger.amount || 0))); + if (!amount) return; + const key = ledgerStageKey(ledger); + if (spendByStage[key] !== undefined) spendByStage[key] += amount; + const day = dateOnly(ledger.created_at); + daily[day] = (daily[day] || 0) + amount; + }); + const stageRows = [ + ["video", "视频片段(Seedance)"], + ["storyboard", "故事板(image-2)"], + ["asset", "基础资产"], + ["script", "脚本 LLM"], + ]; + const stageTotal = stageRows.reduce(function (sum, row) { + return sum + spendByStage[row[0]]; + }, 0); + const denominator = Math.max(stageTotal, used, 1); + const lines = document.querySelectorAll(".stage-pane .usage-line"); + const bars = document.querySelectorAll(".stage-pane .usage-bar > span"); + stageRows.forEach(function (row, index) { + const value = spendByStage[row[0]]; + const valueEl = lines[index]?.querySelector(".v"); + if (valueEl) valueEl.textContent = money(value); + if (bars[index]) bars[index].style.width = Math.min(100, (value / denominator) * 100).toFixed(1) + "%"; + }); + const totalEl = document.querySelector(".stage-pane .total .v"); + if (totalEl) totalEl.textContent = money(used || stageTotal); + + const days = Object.keys(daily).sort().slice(-14); + const values = days.map(function (day) { + return daily[day] || 0; + }); + const sum = values.reduce(function (acc, value) { + return acc + value; + }, 0); + const peak = values.reduce(function (max, value) { + return Math.max(max, value); + }, 0); + const trendSum = document.getElementById("trend-sum"); + const trendAvg = document.getElementById("trend-avg"); + const trendPeak = document.getElementById("trend-peak"); + if (trendSum) trendSum.textContent = money(sum); + if (trendAvg) trendAvg.textContent = money(values.length ? sum / values.length : 0); + if (trendPeak) trendPeak.textContent = money(peak); + } + + function renderLiveAccountPayload(payload) { + const summaryData = payload.summary || {}; + const ledgers = payload.ledgers || []; + const members = payload.members || []; + const projectsData = payload.projects || {}; + const productsData = payload.products || {}; + const projects = projectsData.results || []; + const productMap = {}; + (productsData.results || []).forEach(function (product) { + productMap[product.id] = product.title; + }); + const account = summaryData.account || {}; + const used = Math.abs(Number(summaryData.charged_total || 0)); + const memberLimit = members.reduce(function (sum, member) { + return sum + Math.max(0, Number(member.monthly_credit_limit || 0)); + }, 0); + const limit = memberLimit || Number(account.balance || 0); + const left = Math.max(0, limit - used); + const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + const hero = document.querySelector(".balance-hero .v"); + if (hero) hero.textContent = money(account.balance || 0); + const subValues = document.querySelectorAll(".balance-sub .col .v"); + if (subValues[0]) subValues[0].textContent = money(limit); + if (subValues[1]) subValues[1].textContent = money(used); + const subMeta = document.querySelectorAll(".balance-sub .col .meta"); + if (subMeta[1]) subMeta[1].textContent = "// 占比 " + pct.toFixed(1) + "% · " + (pct >= 80 ? "注意" : "健康"); + const meter = document.querySelector(".balance-meter > span"); + if (meter) meter.style.width = pct.toFixed(1) + "%"; + const foot = document.querySelectorAll(".balance-foot-meta span"); + if (foot[0]) foot[0].textContent = "团队月剩余 " + money(left); + if (foot[1]) foot[1].textContent = "使用率 " + pct.toFixed(1) + "%"; + renderLiveAccountOverview(ledgers, used); + const billsBody = document.getElementById("bills-body"); + if (billsBody) { + billsBody.innerHTML = ledgers.length + ? ledgers + .map(function (ledger) { + const n = Number(ledger.amount || 0); + const cls = n > 0 ? "pos" : n < 0 ? "neg" : "zero"; + return ( + "" + + esc(dateOnly(ledger.created_at)) + + '
' + + esc(ledger.reason || ledger.ledger_type) + + '
' + + esc(ledger.ledger_type) + + '
' + + esc(ledger.id) + + 'U成员真实' + + plainMoney(n) + + "" + ); + }) + .join("") + : '// 暂无真实账单流水'; + } + const billsCount = document.getElementById("bills-count"); + if (billsCount) billsCount.textContent = String(ledgers.length); + const projectBody = document.getElementById("proj-body"); + if (projectBody) { + projectBody.innerHTML = projects.length + ? projects + .map(function (project) { + const status = statusBucket(project); + return ( + '' + + esc(project.name) + + '' + + esc(productMap[project.product] || "未命名商品") + + '' + + initial(members[0]?.user?.username) + + "" + + esc(members[0]?.user?.username || "成员") + + '' + + esc(stageLabel(project)) + + '' + + esc(projectStatusLabel(project)) + + '¥0.00' + ); + }) + .join("") + : '// 暂无真实项目'; + } + const projCount = document.getElementById("proj-count"); + if (projCount) projCount.innerHTML = '共 ' + projects.length + " 个项目 · 消耗 " + money(used); + const memberBody = document.getElementById("member-body"); + if (memberBody) { + memberBody.innerHTML = members.length + ? members + .map(function (member) { + const role = roleUi(member.role); + const name = member.user?.username || member.user?.email || "成员"; + const limitText = Number(member.monthly_credit_limit || 0) > 0 ? money(member.monthly_credit_limit) : "不限"; + return ( + '' + + initial(name) + + "" + + esc(name) + + '' + + role.label + + "0" + + money(0) + + " / " + + limitText + + '真实成员表' + ); + }) + .join("") + : '// 暂无成员'; + } + const memCount = document.getElementById("mem-count"); + if (memCount) memCount.innerHTML = '共 ' + members.length + " 人 · 合计 " + money(used); + document.querySelectorAll(".billing-tabs .tab .count").forEach(function (count) { + const tab = count.closest(".tab")?.dataset.tab; + if (tab === "by-project") count.textContent = String(projects.length); + if (tab === "by-member") count.textContent = String(members.length); + if (tab === "bills") count.textContent = String(ledgers.length); + }); + } + + async function loadLiveAccount() { + if (page !== "account.html" || !canUseApi()) return; + seedAccountBundleFromCache(); + return loadWithCache( + "account:bundle", + async function () { + const summaryData = await apiGet("billing:summary", "/api/billing/summary/"); + writeCache("billing:summary", summaryData); + const ledgers = await apiGet("billing:ledgers", "/api/billing/ledgers/"); + const members = await apiGet("team:members", "/api/auth/team/members/"); + writeCache("team:members", members); + const projectsData = await apiGet("projects:list", "/api/projects/"); + const productsData = await apiGet("products:list", "/api/products/"); + writeCache("products:list", productsData); + return { summary: summaryData, ledgers: ledgers, members: members, projects: projectsData, products: productsData }; + }, + renderLiveAccountPayload, + "账户数据加载失败" + ); + } + + function parseMoneyText(text) { + const n = Number(String(text || "").replace(/[^\d.-]/g, "")); + return Number.isFinite(n) ? n : 0; + } + + function liveTopupPayload() { + const amount = parseMoneyText(document.getElementById("topup-amt")?.textContent || ""); + const bonusText = document.getElementById("topup-bonus")?.textContent || ""; + const bonusMatch = bonusText.match(/含\s*¥?([\d,.]+)/); + const channelText = document.getElementById("topup-channel-label")?.textContent || ""; + return { + amount: amount, + bonus: bonusMatch ? parseMoneyText(bonusMatch[1]) : 0, + channel: channelText.indexOf("支付宝") >= 0 ? "alipay" : "wechat", + }; + } + + async function submitLiveTopup() { + const payload = liveTopupPayload(); + if (payload.amount <= 0) { + toast("充值金额不正确", "请重新选择金额"); + return; + } + try { + await api("/api/billing/recharge/", { + method: "POST", + body: JSON.stringify(payload), + }); + if (window.Shell) Shell.closeModal("topup-bg"); + forgetCache("billing:summary"); + forgetCache("billing:ledgers"); + forgetCache("account:bundle"); + forgetCache("dashboard"); + toast("充值成功", "已写入 Django 账本"); + await loadLiveAccount(); + } catch (error) { + toast("充值确认失败", error.message); + } + } + + function wireLiveAccount() { + if (page !== "account.html" || window.__airshelfLiveAccountWired) return; + window.__airshelfLiveAccountWired = true; + const fallbackTopupDone = window.topupDone; + window.topupDone = function () { + if (!canUseApi()) { + if (typeof fallbackTopupDone === "function") fallbackTopupDone(); + return; + } + submitLiveTopup(); + }; + } + + function liveTeamMemberRowHTML(member) { + const name = member.user?.first_name || member.user?.username || member.user?.email || "成员"; + const role = roleUi(member.role); + const monthly = Number(member.monthly_credit_limit || 0); + const usedPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0; + const isOwner = member.role === "owner"; + const actions = isOwner + ? '不可编辑' + : ''; + return ( + '' + + initial(name) + + '' + + esc(name) + + '' + + esc(member.user?.email || "") + + '' + + role.label + + '不限' + + (monthly > 0 ? money(monthly) : "不限") + + '
¥0.00 / ' + + usedPct.toFixed(0) + + '%
' + + actions + + "
" + ); + } + + function renderLiveTeamMembers(filterText) { + const tbody = document.getElementById("members-tbody"); + if (!tbody) return; + const members = window.__airshelfLiveTeamMembers || []; + const needle = String(filterText || "").trim().toLowerCase(); + const list = members.filter(function (member) { + const name = member.user?.first_name || member.user?.username || ""; + const email = member.user?.email || ""; + return !needle || (name + " " + email).toLowerCase().indexOf(needle) >= 0; + }); + tbody.innerHTML = list.length + ? list.map(liveTeamMemberRowHTML).join("") + : '// 没有匹配的真实成员'; + const headingCount = document.querySelector(".members-table")?.closest(".pane")?.querySelector("h3 .ct"); + if (headingCount) headingCount.textContent = "// " + list.length + " / " + members.length + " 人 · 真实团队表"; + } + + function liveMemberById(id) { + return (window.__airshelfLiveTeamMembers || []).find(function (member) { + return String(member.id) === String(id); + }); + } + + function selectedRoleValue(selector, fallback) { + const selected = document.querySelector(selector + " .role-choice.selected"); + return selected?.dataset.role || selected?.dataset.editRole || fallback || "member"; + } + + async function refreshLiveTeamAfterMutation() { + forgetCache("team:members"); + forgetCache("team:bundle"); + forgetCache("account:bundle"); + await loadLiveTeam(); + } + + async function submitLiveTeamMember(button) { + const username = (document.getElementById("inv-username")?.value || "").trim(); + const password = (document.getElementById("inv-password")?.value || "").trim(); + const name = (document.getElementById("inv-name")?.value || "").trim() || username; + const monthly = Number(document.getElementById("inv-monthly")?.value || 0); + if (!username || !password) { + toast("请填写用户名和密码", "成员未创建"); + return; + } + const restore = setButtonBusy(button, "创建中..."); + try { + const member = await api("/api/auth/team/members/", { + method: "POST", + body: JSON.stringify({ + username: username, + password: password, + name: name, + role: selectedRoleValue("#invite-bg", "member"), + monthly_credit_limit: Number.isFinite(monthly) ? monthly : 0, + }), + }); + if (window.Shell) Shell.closeModal("invite-bg"); + const shareUser = document.getElementById("share-username"); + const sharePassword = document.getElementById("share-password"); + if (shareUser) shareUser.textContent = username; + if (sharePassword) sharePassword.textContent = password; + if (window.Shell) Shell.openModal("invite-share-bg"); + toast("账户已创建", (member.user?.username || username) + " · 已写入团队表"); + await refreshLiveTeamAfterMutation(); + } catch (error) { + toast("创建成员失败", error.message); + } finally { + restore(); + } + } + + function openLiveTeamEdit(memberId) { + const member = liveMemberById(memberId); + if (!member) return; + window.__airshelfEditingMemberId = memberId; + const name = member.user?.first_name || member.user?.username || ""; + const title = document.getElementById("edit-username"); + const input = document.getElementById("edit-name-readonly"); + if (title) title.textContent = name; + if (input) input.value = name; + document.querySelectorAll("#edit-role-choices .role-choice").forEach(function (choice) { + choice.classList.toggle("selected", choice.dataset.editRole === member.role || (member.role === "owner" && choice.dataset.editRole === "super")); + }); + const daily = document.getElementById("edit-daily"); + const monthly = document.getElementById("edit-monthly"); + const total = document.getElementById("edit-total"); + if (daily) daily.value = "-1"; + if (monthly) monthly.value = Number(member.monthly_credit_limit || 0); + if (total) total.value = "-1"; + if (window.Shell) Shell.openModal("edit-member-bg"); + } + + async function submitLiveTeamMemberEdit(button) { + const memberId = window.__airshelfEditingMemberId; + const monthly = Number(document.getElementById("edit-monthly")?.value || 0); + const restore = setButtonBusy(button, "保存中..."); + try { + await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/", { + method: "PATCH", + body: JSON.stringify({ + name: (document.getElementById("edit-name-readonly")?.value || "").trim(), + role: selectedRoleValue("#edit-role-choices", "member"), + monthly_credit_limit: Number.isFinite(monthly) ? monthly : 0, + }), + }); + window.__airshelfEditingMemberId = null; + if (window.Shell) Shell.closeModal("edit-member-bg"); + toast("成员已保存", "已写入 Django 团队表"); + await refreshLiveTeamAfterMutation(); + } catch (error) { + toast("保存成员失败", error.message); + } finally { + restore(); + } + } + + function openLiveTeamPassword(memberId) { + const member = liveMemberById(memberId); + if (!member) return; + window.__airshelfPasswordMemberId = memberId; + const name = member.user?.first_name || member.user?.username || "成员"; + const title = document.getElementById("reset-pwd-name"); + if (title) title.textContent = name; + const input = document.getElementById("reset-pwd-input"); + if (input && !input.value.trim()) input.value = "Airshelf" + Math.floor(100000 + Math.random() * 900000); + if (window.Shell) Shell.openModal("reset-pwd-bg"); + } + + async function submitLiveTeamPassword(button) { + const memberId = window.__airshelfPasswordMemberId; + const password = (document.getElementById("reset-pwd-input")?.value || "").trim(); + const restore = setButtonBusy(button, "重置中..."); + try { + await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/password/", { + method: "POST", + body: JSON.stringify({ password: password }), + }); + window.__airshelfPasswordMemberId = null; + if (window.Shell) Shell.closeModal("reset-pwd-bg"); + toast("密码已重置", "成员旧会话已失效"); + } catch (error) { + toast("重置密码失败", error.message); + } finally { + restore(); + } + } + + async function removeLiveTeamMember(memberId) { + const member = liveMemberById(memberId); + const name = member?.user?.first_name || member?.user?.username || "成员"; + if (!confirm("确定将「" + name + "」移出团队?")) return; + try { + await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/", { method: "DELETE" }); + toast("成员已移出", name); + await refreshLiveTeamAfterMutation(); + } catch (error) { + toast("移出失败", error.message); + } + } + + function runLiveTeamAction(action, memberId) { + if (action === "edit") openLiveTeamEdit(memberId); + if (action === "password") openLiveTeamPassword(memberId); + if (action === "remove") removeLiveTeamMember(memberId); + } + + function wireLiveTeam() { + if (page !== "team.html" || window.__airshelfLiveTeamWired) return; + window.__airshelfLiveTeamWired = true; + const search = document.getElementById("member-search"); + if (search) { + search.addEventListener( + "input", + function (event) { + if (!canUseApi()) return; + event.stopImmediatePropagation(); + renderLiveTeamMembers(search.value); + }, + true + ); + } + document.addEventListener( + "click", + function (event) { + if (!canUseApi()) return; + const actionButton = event.target && event.target.closest ? event.target.closest("[data-live-team-action]") : null; + if (actionButton) { + event.preventDefault(); + event.stopImmediatePropagation(); + runLiveTeamAction(actionButton.dataset.liveTeamAction, actionButton.dataset.memberId); + return; + } + const createButton = event.target && event.target.closest ? event.target.closest("#inv-send") : null; + if (createButton) { + event.preventDefault(); + event.stopImmediatePropagation(); + submitLiveTeamMember(createButton); + return; + } + const editButton = event.target && event.target.closest ? event.target.closest("#edit-save") : null; + if (editButton && window.__airshelfEditingMemberId) { + event.preventDefault(); + event.stopImmediatePropagation(); + submitLiveTeamMemberEdit(editButton); + return; + } + const pwdButton = event.target && event.target.closest ? event.target.closest("#reset-pwd-confirm") : null; + if (pwdButton && window.__airshelfPasswordMemberId) { + event.preventDefault(); + event.stopImmediatePropagation(); + submitLiveTeamPassword(pwdButton); + } + }, + true + ); + } + + function renderLiveTeamActivity(members) { + const feed = document.querySelector(".team-feed .feed-list"); + if (feed) { + const count = members.length; + feed.innerHTML = + '
Q
真实团队表已同步' + + esc(count) + + ' 名成员
local cache
'; + } + const feedCount = document.querySelector(".team-feed .h .ct"); + if (feedCount) feedCount.textContent = "// 真实动态接口待接入"; + const more = document.getElementById("open-feed-all"); + if (more) more.hidden = true; + const allList = document.getElementById("feed-all-list"); + if (allList) allList.innerHTML = '
// 暂无真实团队动态
'; + const allCount = document.getElementById("feed-all-count"); + if (allCount) allCount.textContent = "// 共 0 条"; + const allMeta = document.getElementById("feed-all-meta"); + if (allMeta) allMeta.textContent = "// 共 0 条"; + } + + function renderLiveTeamPayload(payload) { + const meData = payload.me || {}; + const summaryData = payload.summary || {}; + const members = payload.members || []; + rememberAuthShape(meData); + window.__airshelfLiveTeamMembers = members; + const account = summaryData.account || {}; + const used = Number(summaryData.charged_total || 0); + const limit = members.reduce(function (sum, member) { + return sum + Math.max(0, Number(member.monthly_credit_limit || 0)); + }, 0) || Number(account.balance || 0); + const left = Math.max(0, limit - used); + const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + const nameEl = document.querySelector(".banner-id .nm"); + if (nameEl) nameEl.innerHTML = esc(meData.team?.name || "团队") + ' 企业'; + const metaEl = document.querySelector(".banner-id .meta"); + if (metaEl) metaEl.textContent = "// 团队 ID: " + (meData.team?.id || "-") + " · " + members.length + " 名成员"; + const statValues = document.querySelectorAll(".banner-stats .stat .v"); + if (statValues[0]) statValues[0].textContent = money(account.balance || 0); + if (statValues[1]) statValues[1].textContent = money(limit); + if (statValues[2]) statValues[2].textContent = money(used); + if (statValues[3]) statValues[3].textContent = money(left); + const usedSub = document.getElementById("stat-used-sub"); + const leftSub = document.getElementById("stat-left-sub"); + if (usedSub) usedSub.textContent = "// 占月限 " + pct.toFixed(1) + "%"; + if (leftSub) leftSub.textContent = "// 还可生成约 " + Math.max(0, Math.round(left / 10)) + " 个项目"; + renderLiveTeamActivity(members); + renderLiveTeamMembers((document.getElementById("member-search") || {}).value || ""); + } + + async function loadLiveTeam() { + if (page !== "team.html" || !canUseApi()) return; + seedTeamBundleFromCache(); + return loadWithCache( + "team:bundle", + async function () { + const meData = await apiGet("auth:me", "/api/auth/me/"); + writeCache("auth:me", meData); + const summaryData = await apiGet("billing:summary", "/api/billing/summary/"); + writeCache("billing:summary", summaryData); + const members = await apiGet("team:members", "/api/auth/team/members/"); + writeCache("team:members", members); + return { me: meData, summary: summaryData, members: members }; + }, + renderLiveTeamPayload, + "团队数据加载失败" + ); + } + + function renderLivePipelinePayload(payload) { + const project = payload.project; + const productsData = payload.products || {}; + if (!project) return; + const productMap = {}; + (productsData.results || []).forEach(function (product) { + productMap[product.id] = product; + }); + localStorage.setItem("airshelf_current_project_id", project.id); + const product = productMap[project.product] || {}; + const productName = product.title || "未命名商品"; + const title = document.querySelector(".pipeline-topbar-title"); + if (title) { + title.innerHTML = + '
' + + esc(project.name) + + '
// ' + + esc(productName) + + " · " + + esc(projectStatusLabel(project)) + + "
"; + } + ["asset-prod-name", "asset-prod-card-name"].forEach(function (id) { + const el = document.getElementById(id); + if (el) el.textContent = productName; + }); + const thumb = document.getElementById("asset-prod-thumb-label"); + if (thumb) thumb.textContent = productName + " · 主图"; + const cat = document.querySelector("#asset-prod-card .prod-cat"); + if (cat) cat.textContent = product.category || "未分类"; + const date = document.querySelector("#asset-prod-card .prod-date"); + if (date) date.textContent = dateOnly(product.created_at) + " 创建"; + const activeNo = stageNo(project); + document.querySelectorAll("#stage-pill .sp-dot").forEach(function (dot) { + const no = Number(dot.dataset.stage); + dot.classList.toggle("done", project.status === "completed" || no < activeNo); + dot.classList.toggle("active", no === activeNo && project.status !== "completed"); + dot.classList.toggle("fail", project.status === "failed" && no === activeNo); + }); + document.querySelectorAll("#stage-pill .sp-line").forEach(function (line, index) { + line.classList.toggle("done", index + 1 < activeNo || project.status === "completed"); + }); + } + + async function loadLivePipeline() { + if (page !== "pipeline.html" || !canUseApi()) return; + const projectId = query.get("project_id") || localStorage.getItem("airshelf_current_project_id"); + return loadWithCache( + "pipeline:" + (projectId || "latest"), + async function () { + const productsData = await apiGet("products:list", "/api/products/"); + writeCache("products:list", productsData); + let project = null; + if (projectId) project = await apiGet("project:" + projectId, "/api/projects/" + encodeURIComponent(projectId) + "/"); + if (!project) { + const data = await apiGet("projects:list", "/api/projects/"); + project = (data.results || [])[0]; + } + return new Promise(function (resolve) { + setTimeout(function () { + resolve({ products: productsData, project: project }); + }, 120); + }); + }, + renderLivePipelinePayload, + "Pipeline 数据加载失败" + ); + } + + function monthStart() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); + } + + function isThisMonth(value) { + if (!value) return false; + const date = new Date(value); + return !Number.isNaN(date.getTime()) && date >= monthStart(); + } + + function moneyHTML(value) { + const text = money(value).replace("¥", ""); + const parts = text.split("."); + return "¥" + esc(parts[0]) + (parts[1] ? "." + esc(parts[1]) + "" : ""); + } + + function dashboardRecentRowHTML(project, productMap) { + const product = productMap[project.product] || {}; + const productName = product.title || "未命名商品"; + const href = projectHref(project); + const shots = (project.video_segments && project.video_segments.length) || 4; + return ( + '
9:16
' + + esc(project.name || "未命名项目") + + '
' + + esc(productName) + + " / AI 全生 / " + + esc(shots) + + ' 镜
' + + progressHTML(project) + + '
' + + esc(projectStatusLabel(project)) + + '' + + (project.status === "completed" ? "打开" : "继续") + + "
" + ); + } + + function renderLiveDashboardPayload(payload) { + const meData = payload.me || {}; + const summaryData = payload.summary || {}; + const productsData = payload.products || {}; + const projectsData = payload.projects || {}; + const assetsData = payload.assets || {}; + rememberAuthShape(meData); + + const projects = projectsData.results || []; + const products = productsData.results || []; + const assets = assetsData.results || []; + const productMap = {}; + products.forEach(function (product) { + productMap[product.id] = product; + }); + const wip = projects.filter(function (project) { + return project.status !== "completed" && project.status !== "failed"; + }).length; + const done = projects.filter(function (project) { + return project.status === "completed"; + }).length; + const monthDone = projects.filter(function (project) { + return project.status === "completed" && isThisMonth(project.updated_at || project.created_at); + }).length; + const failed = projects.filter(function (project) { + return project.status === "failed"; + }).length; + const balance = Number(summaryData.account?.balance || 0); + const charged = Number(summaryData.charged_total || 0); + const budget = Math.max(balance + charged, 1); + const budgetPct = Math.min(100, (charged / budget) * 100); + + const h1 = document.querySelector(".page-head h1"); + if (h1) h1.textContent = "欢迎回来," + displayName(meData.user); + const sub = document.querySelector(".page-head .sub"); + if (sub) { + const now = new Date(); + const day = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" }).format(now); + sub.innerHTML = + '// ' + + esc(day.replace(/\//g, ".")) + + '·你有 ' + + esc(wip) + + " 个项目 正在进行中"; + } + + const stats = document.querySelectorAll(".stats .stat"); + if (stats[0]) { + const v = stats[0].querySelector(".v"); + const d = stats[0].querySelector(".delta"); + if (v) v.textContent = String(projects.length); + if (d) d.textContent = "本月完成 " + monthDone; + } + if (stats[1]) { + const v = stats[1].querySelector(".v"); + const d = stats[1].querySelector(".delta"); + if (v) v.textContent = String(wip); + if (d) d.textContent = failed ? failed + " 个失败需处理" : "全部正常推进"; + } + if (stats[2]) { + const v = stats[2].querySelector(".v"); + const d = stats[2].querySelector(".delta"); + if (v) v.textContent = String(monthDone || done); + if (d) d.textContent = "累计完成 " + done; + } + if (stats[3]) { + const v = stats[3].querySelector(".v"); + const bar = stats[3].querySelector(".bar span"); + const subText = stats[3].querySelector(".sub"); + if (v) v.innerHTML = moneyHTML(balance); + if (bar) bar.style.width = budgetPct.toFixed(0) + "%"; + if (subText) subText.textContent = "已用 " + money(charged) + " / " + money(budget); + } + + const more = document.querySelector(".dash-grid .section-h .more[href='projects.html']"); + if (more) more.textContent = "[ ALL · " + projects.length + " ]"; + const recentBox = document.querySelector(".card-hard"); + if (recentBox) { + const recent = projects.slice(0, 5); + recentBox.innerHTML = recent.length + ? recent + .map(function (project) { + return dashboardRecentRowHTML(project, productMap); + }) + .join("") + : '
// 当前团队还没有真实项目
'; + } + const shortcutData = [ + products.length + " SKU", + "资产 " + assets.length + " 个", + money(balance), + projects.length + " 个", + ]; + document.querySelectorAll(".shortcut .d").forEach(function (el, index) { + if (shortcutData[index]) el.textContent = shortcutData[index]; + }); + } + + async function loadLiveDashboard() { + if (page !== "index.html" || !canUseApi()) return; + return loadWithCache( + "dashboard", + async function () { + const meData = await apiGet("auth:me", "/api/auth/me/"); + writeCache("auth:me", meData); + const summaryData = await apiGet("billing:summary", "/api/billing/summary/"); + writeCache("billing:summary", summaryData); + const productsData = await apiGet("products:list", "/api/products/"); + writeCache("products:list", productsData); + const projectsData = await apiGet("projects:list", "/api/projects/"); + const assetsData = await apiGet("assets:list", "/api/assets/"); + writeCache("assets:list", assetsData); + return { me: meData, summary: summaryData, products: productsData, projects: projectsData, assets: assetsData }; + }, + renderLiveDashboardPayload, + "工作台数据加载失败" + ); + } + + function settingsRowByLabel(label) { + return Array.from(document.querySelectorAll("#sec-profile .form-row")).find(function (row) { + const lbl = row.querySelector(".lbl"); + return lbl && lbl.textContent.indexOf(label) >= 0; + }); + } + + function collectSettingsPrefs() { + const fields = {}; + document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(function (el) { + if (!el.id) return; + fields[el.id] = el.type === "checkbox" ? Boolean(el.checked) : el.value; + }); + const choices = {}; + ["pref-template", "pref-duration", "pref-subtitle"].forEach(function (id) { + const selected = document.querySelector("#" + id + " .selected"); + if (selected) choices[id] = selected.dataset.v || selected.textContent.trim(); + }); + const avatar = document.getElementById("prof-avatar-preview"); + return { + fields: fields, + choices: choices, + avatarText: avatar ? avatar.textContent.trim() : "", + }; + } + + function applySettingsPrefs(prefs) { + if (!prefs) return; + Object.keys(prefs.fields || {}).forEach(function (id) { + const el = document.getElementById(id); + if (!el) return; + if (el.type === "checkbox") el.checked = Boolean(prefs.fields[id]); + else el.value = prefs.fields[id]; + }); + Object.keys(prefs.choices || {}).forEach(function (id) { + const value = prefs.choices[id]; + document.querySelectorAll("#" + id + " .pref-choice, #" + id + " .dur-chip").forEach(function (node) { + const nodeValue = node.dataset.v || node.textContent.trim(); + node.classList.toggle("selected", nodeValue === value); + }); + }); + if (prefs.avatarText) { + ["prof-avatar-preview", "av-up-preview"].forEach(function (id) { + const el = document.getElementById(id); + if (el) el.textContent = prefs.avatarText; + }); + } + } + + function renderLiveSettingsPayload(payload) { + const meData = payload.me || {}; + const members = payload.members || []; + rememberAuthShape(meData); + const user = meData.user || {}; + const team = meData.team || {}; + const name = displayName(user); + const email = user.email || ""; + const phone = user.phone || ""; + const avatar = initial(name); + const member = + members.find(function (item) { + return item.user?.id === user.id || item.user?.email === email; + }) || members[0] || {}; + const role = roleUi(member.role); + + const avatarEl = document.getElementById("prof-avatar-preview"); + if (avatarEl) avatarEl.textContent = avatar; + const avatarUp = document.getElementById("av-up-preview"); + if (avatarUp) avatarUp.textContent = avatar; + const avatarName = document.getElementById("av-up-preview-name"); + if (avatarName) avatarName.textContent = "当前头像 · 默认"; + const avatarInfo = document.getElementById("av-up-preview-info"); + if (avatarInfo) avatarInfo.textContent = "// 系统生成 · 取显示名称首字"; + + const nameInput = document.getElementById("prof-name"); + const emailInput = document.getElementById("prof-email"); + const phoneInput = document.getElementById("prof-phone"); + if (nameInput) nameInput.value = name; + if (emailInput) emailInput.value = email; + if (phoneInput) phoneInput.value = phone; + + const emailBtn = emailInput?.closest(".val")?.querySelector("button"); + if (emailBtn) emailBtn.onclick = function () { Shell.toast("已发送验证邮件", email || "当前邮箱"); }; + const phoneBtn = phoneInput?.closest(".val")?.querySelector("button"); + if (phoneBtn) phoneBtn.onclick = function () { Shell.toast("已发送短信验证码", phone || "未绑定手机号"); }; + + const teamRow = settingsRowByLabel("所属团队"); + if (teamRow) { + const staticEl = teamRow.querySelector(".static"); + const roleEl = teamRow.querySelector(".role-tag"); + if (staticEl) staticEl.textContent = team.name || "团队"; + if (roleEl) roleEl.innerHTML = '' + esc(role.label) + (member.role === "owner" ? " · 创建者" : ""); + } + const idRow = settingsRowByLabel("用户 ID"); + if (idRow) { + const idEl = idRow.querySelector(".static"); + if (idEl) idEl.textContent = userPublicId(user); + } + + applySettingsPrefs(readCache("settings:prefs")); + } + + function wireLiveSettings() { + if (page !== "settings.html") return; + const save = document.getElementById("save-btn"); + if (save) { + save.addEventListener("click", function () { + setTimeout(function () { + const invalid = document.querySelector("#sec-profile .input.invalid"); + if (!invalid) { + writeCache("settings:prefs", collectSettingsPrefs()); + toast("本地设置已更新", "已写入 localStorage, 下次访问会先显示本地数据"); + } + }, 0); + }); + } + const reset = document.getElementById("prof-avatar-reset"); + if (reset) { + reset.addEventListener("click", function () { + setTimeout(function () { + const current = readCache("settings:profile"); + if (current) renderLiveSettingsPayload(current); + }, 0); + }); + } + } + + async function loadLiveSettings() { + if (page !== "settings.html" || !canUseApi()) return; + if (!readCache("settings:profile")) { + const me = cachedAuthShape(); + if (me) { + writeCache("settings:profile", { + me: me, + summary: readCache("billing:summary"), + members: readCache("team:members") || [], + }); + } + } + return loadWithCache( + "settings:profile", + async function () { + const meData = await apiGet("auth:me", "/api/auth/me/"); + writeCache("auth:me", meData); + const summaryData = await apiGet("billing:summary", "/api/billing/summary/").catch(function () { + return null; + }); + if (summaryData) writeCache("billing:summary", summaryData); + const members = await apiGet("team:members", "/api/auth/team/members/").catch(function () { + return []; + }); + writeCache("team:members", members); + return { me: meData, summary: summaryData, members: members }; + }, + renderLiveSettingsPayload, + "设置数据加载失败" + ); + } + + const messageTypeLabel = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" }; + const messageTypeIcon = { task: "clapperboard", team: "users", billing: "creditCard", system: "info" }; + const messagePriorityLabel = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" }; + + function messageIcon(name) { + return window.IconKit && typeof window.IconKit.svg === "function" ? window.IconKit.svg(name) : ""; + } + + function messageDate(value) { + const date = value ? new Date(value) : new Date(); + return Number.isNaN(date.getTime()) ? new Date() : date; + } + + function messageTimeAgo(value) { + const diff = Math.max(0, Date.now() - messageDate(value).getTime()); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "刚刚"; + if (minutes < 60) return minutes + "m"; + if (minutes < 1440) return Math.floor(minutes / 60) + "h"; + return Math.floor(minutes / 1440) + "d"; + } + + function messageFullTime(value) { + const date = messageDate(value); + const z = function (n) { + return String(n).padStart(2, "0"); + }; + return date.getFullYear() + "-" + z(date.getMonth() + 1) + "-" + z(date.getDate()) + " " + z(date.getHours()) + ":" + z(date.getMinutes()); + } + + function notificationType(item) { + return item.type || item.notification_type || "system"; + } + + function notificationUnread(item) { + if (item.unread !== undefined) return Boolean(item.unread); + return item.is_read !== true; + } + + function normalizeNotification(item) { + const metadata = item.metadata || {}; + const type = notificationType(item); + return { + id: String(item.id || item.dedupe_key || item.title || ""), + type: type, + priority: item.priority || metadata.priority || "info", + unread: notificationUnread(item), + title: item.title || "系统消息", + brief: item.brief || metadata.brief || "", + body: item.body || item.brief || metadata.body || "暂无详情。", + source: item.source || metadata.source || "Airshelf", + project: item.project_name || metadata.project_name || metadata.project || "系统", + stage: item.stage || metadata.stage || "通知", + owner: item.owner_label || metadata.owner || metadata.owner_label || "系统", + cost: item.cost_label || metadata.cost || metadata.cost_label || "-", + href: item.related_url || metadata.href || metadata.related_url || "", + time: item.created_at || item.updated_at || item.time || new Date().toISOString(), + metadata: metadata, + raw: item, + }; + } + + function normalizeNotificationsPayload(data) { + const rows = listResults(data).map(normalizeNotification).filter(function (item) { + return item.id; + }); + const unread = unreadNotificationCount(data); + return { + count: collectionCount(data) ?? rows.length, + unread_count: unread === null || unread === undefined ? rows.filter(function (item) { return item.unread; }).length : unread, + results: rows, + }; + } + + function notificationCachePayload(messages) { + const rows = (messages || []).map(function (message) { + return { + id: message.id, + type: message.type, + notification_type: message.type, + priority: message.priority, + title: message.title, + brief: message.brief, + body: message.body, + source: message.source, + project_name: message.project, + stage: message.stage, + owner_label: message.owner, + cost_label: message.cost, + related_url: message.href, + is_read: !message.unread, + unread: message.unread, + created_at: message.time, + metadata: message.metadata || {}, + }; + }); + return { + count: rows.length, + next: null, + previous: null, + unread_count: rows.filter(function (item) { return item.unread; }).length, + results: rows, + }; + } + + function persistLiveMessages(messages) { + const payload = notificationCachePayload(messages || []); + delete requestMemo["notifications:list"]; + writeCache("notifications:list", payload); + writeCache("notifications:summary", { count: payload.count, unread_count: payload.unread_count }); + return payload; + } + + function liveMessageState() { + if (!window.__airshelfLiveMessageState) { + window.__airshelfLiveMessageState = { tab: "all", q: "", selectedId: null, showLogId: null }; + } + return window.__airshelfLiveMessageState; + } + + function liveMessages() { + return Array.isArray(window.__airshelfLiveMessages) ? window.__airshelfLiveMessages : []; + } + + function liveVisibleMessages() { + const state = liveMessageState(); + const q = String(state.q || "").trim().toLowerCase(); + return liveMessages().filter(function (message) { + if (state.tab === "unread" && !message.unread) return false; + if (!["all", "unread"].includes(state.tab) && message.type !== state.tab) return false; + if (!q) return true; + return [message.title, message.brief, message.body, message.source, message.project, message.stage] + .join(" ") + .toLowerCase() + .indexOf(q) >= 0; + }); + } + + function liveMessageCounts() { + const rows = liveMessages(); + return { + all: rows.length, + unread: rows.filter(function (message) { return message.unread; }).length, + task: rows.filter(function (message) { return message.type === "task"; }).length, + team: rows.filter(function (message) { return message.type === "team"; }).length, + billing: rows.filter(function (message) { return message.type === "billing"; }).length, + system: rows.filter(function (message) { return message.type === "system"; }).length, + }; + } + + function liveMessageTimeline(message) { + if (Array.isArray(message.metadata?.timeline)) return message.metadata.timeline; + const created = messageFullTime(message.time); + const rows = [[created, "通知写入团队消息中心"]]; + if (!message.unread) rows.push([messageFullTime(message.raw?.read_at || message.time), "当前用户已读"]); + return rows; + } + + function renderLiveMessageFilters() { + const box = document.getElementById("msg-filters"); + if (!box) return; + const state = liveMessageState(); + const c = liveMessageCounts(); + const filters = [ + ["all", "全部", c.all], + ["unread", "未读", c.unread], + ["task", "任务", c.task], + ["team", "团队", c.team], + ["billing", "计费", c.billing], + ["system", "系统", c.system], + ]; + box.innerHTML = filters + .map(function (filter) { + return ( + '" + ); + }) + .join(""); + box.querySelectorAll("[data-live-tab]").forEach(function (button) { + button.addEventListener("click", function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + state.tab = button.dataset.liveTab || "all"; + renderLiveMessages(); + }); + }); + } + + function renderLiveMessageList() { + const listEl = document.getElementById("msg-list"); + const countEl = document.getElementById("msg-list-count"); + if (!listEl) return; + const state = liveMessageState(); + const list = liveVisibleMessages(); + if (countEl) countEl.textContent = "// 显示 " + list.length + " 条"; + if (!list.length) { + listEl.innerHTML = '
' + messageIcon("search") + "没有符合条件的消息
"; + return; + } + listEl.innerHTML = list + .map(function (message) { + return ( + '" + ); + }) + .join(""); + listEl.querySelectorAll("[data-live-message-id]").forEach(function (button) { + button.addEventListener("click", function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + selectLiveMessage(button.dataset.liveMessageId); + }); + }); + } + + function renderLiveMessageDetail() { + const detail = document.getElementById("msg-detail"); + if (!detail) return; + const state = liveMessageState(); + const list = liveMessages(); + let message = list.find(function (item) { + return item.id === state.selectedId; + }); + if (!message) message = liveVisibleMessages()[0] || list[0]; + if (!message) { + state.selectedId = null; + detail.innerHTML = '
' + messageIcon("bell") + "
暂无消息
"; + return; + } + state.selectedId = message.id; + const props = [ + ["来源", message.source], + ["类别", messageTypeLabel[message.type] || "系统"], + ["项目", message.project], + ["阶段", message.stage], + ["负责人", message.owner], + ["费用", message.cost], + ["时间", messageFullTime(message.time)], + ["关联资源", message.href ? '' + esc(message.project) + " →" : "无"], + ] + .map(function (row) { + return '' + esc(row[0]) + '' + row[1] + ""; + }) + .join(""); + const timeline = liveMessageTimeline(message) + .map(function (row) { + return '
' + esc(row[0]) + '' + esc(row[1]) + "
"; + }) + .join(""); + const primaryAction = message.href + ? '' + : ''; + const readAction = message.unread + ? '' + : ''; + detail.innerHTML = + '
' + + messageIcon(messageTypeIcon[message.type] || "info") + + '

' + + esc(message.title) + + '

' + + esc(message.source) + + "// " + + esc(messageTypeLabel[message.type] || "系统") + + "" + + esc(messageFullTime(message.time)) + + '
' + + esc(messagePriorityLabel[message.priority] || "更新") + + '

' + + esc(message.body) + + '

' + + props + + '
处理记录
' + + timeline + + '
' + + readAction + + primaryAction + + "
"; + detail.querySelectorAll("[data-live-action]").forEach(function (button) { + button.addEventListener("click", function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + runLiveMessageAction(message.id, button.dataset.liveAction, button); + }); + }); + } + + function renderLiveMessages() { + const head = document.getElementById("msg-head-sub"); + const counts = liveMessageCounts(); + if (head) head.textContent = "// " + counts.unread + " 条未读 · " + counts.all + " 条总计"; + renderLiveMessageFilters(); + renderLiveMessageList(); + renderLiveMessageDetail(); + persistLiveMessages(liveMessages()); + } + + function setLiveMessageReadState(id, unread, response) { + const rows = liveMessages(); + const next = response ? normalizeNotification(response) : null; + const index = rows.findIndex(function (item) { + return item.id === id; + }); + if (index < 0) return; + if (next) rows[index] = next; + else rows[index].unread = unread; + window.__airshelfLiveMessages = rows; + renderLiveMessages(); + } + + async function markLiveMessageRead(id) { + const message = liveMessages().find(function (item) { + return item.id === id; + }); + if (!message || !message.unread) return; + setLiveMessageReadState(id, false); + try { + const response = await api("/api/ops/notifications/" + encodeURIComponent(id) + "/mark-read/", { + method: "POST", + body: JSON.stringify({}), + }); + setLiveMessageReadState(id, false, response); + } catch (error) { + setLiveMessageReadState(id, true); + toast("已读状态保存失败", error.message); + } + } + + async function markLiveMessageUnread(id) { + setLiveMessageReadState(id, true); + try { + const response = await api("/api/ops/notifications/" + encodeURIComponent(id) + "/mark-unread/", { + method: "POST", + body: JSON.stringify({}), + }); + setLiveMessageReadState(id, true, response); + } catch (error) { + setLiveMessageReadState(id, false); + toast("未读状态保存失败", error.message); + } + } + + function selectLiveMessage(id) { + const state = liveMessageState(); + state.selectedId = id; + renderLiveMessages(); + markLiveMessageRead(id); + } + + async function archiveLiveMessage(id, button) { + const message = liveMessages().find(function (item) { + return item.id === id; + }); + if (!message) return; + const restore = setButtonBusy(button, "归档中..."); + try { + await api("/api/ops/notifications/" + encodeURIComponent(id) + "/archive/", { + method: "POST", + body: JSON.stringify({}), + }); + window.__airshelfLiveMessages = liveMessages().filter(function (item) { + return item.id !== id; + }); + const state = liveMessageState(); + state.selectedId = liveMessages()[0]?.id || null; + renderLiveMessages(); + toast("已归档", message.title); + } catch (error) { + toast("归档失败", error.message); + } finally { + restore(); + } + } + + async function markAllLiveMessagesRead(button) { + const rows = liveMessages(); + if (!rows.length) return; + const restore = setButtonBusy(button, "保存中..."); + try { + await api("/api/ops/notifications/mark-all-read/", { + method: "POST", + body: JSON.stringify({}), + }); + rows.forEach(function (message) { + message.unread = false; + }); + window.__airshelfLiveMessages = rows; + renderLiveMessages(); + toast("已全部标为已读", rows.length + " 条消息已写入数据库"); + } catch (error) { + toast("批量更新失败", error.message); + } finally { + restore(); + } + } + + function runLiveMessageAction(id, action, button) { + const message = liveMessages().find(function (item) { + return item.id === id; + }); + if (!message) return; + if (action === "goto") { + go(message.href || "settings.html#sec-notify"); + return; + } + if (action === "settings") { + go("settings.html#sec-notify"); + return; + } + if (action === "ack") { + markLiveMessageRead(id); + return; + } + if (action === "unread") { + markLiveMessageUnread(id); + return; + } + if (action === "archive") { + archiveLiveMessage(id, button); + return; + } + if (action === "mute") { + toast("已记录静音偏好", (messageTypeLabel[message.type] || "系统") + " 类提醒设置入口在通知设置"); + } + } + + function renderLiveMessagesPayload(data) { + const payload = normalizeNotificationsPayload(data); + window.__airshelfLiveMessages = payload.results; + const state = liveMessageState(); + if (!payload.results.some(function (message) { return message.id === state.selectedId; })) { + state.selectedId = payload.results[0]?.id || null; + } + renderLiveMessages(); + } + + function wireLiveMessages() { + if (page !== "messages.html" || window.__airshelfLiveMessagesWired) return; + window.__airshelfLiveMessagesWired = true; + const search = document.getElementById("msg-search"); + if (search) { + search.addEventListener( + "input", + function (event) { + if (!canUseApi()) return; + event.preventDefault(); + event.stopImmediatePropagation(); + liveMessageState().q = search.value.trim(); + renderLiveMessages(); + }, + true + ); + } + const markAll = document.getElementById("msg-mark-all"); + if (markAll) { + markAll.addEventListener( + "click", + function (event) { + if (!canUseApi()) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + markAllLiveMessagesRead(markAll); + }, + true + ); + } + const settings = document.getElementById("msg-settings"); + if (settings) { + settings.addEventListener( + "click", + function (event) { + if (!canUseApi()) return; + event.preventDefault(); + event.stopImmediatePropagation(); + go("settings.html#sec-notify"); + }, + true + ); + } + } + + async function loadLiveMessages() { + if (page !== "messages.html" || !canUseApi()) return; + return loadWithCache( + "notifications:list", + function () { + return apiGet("notifications:list", "/api/ops/notifications/"); + }, + renderLiveMessagesPayload, + "消息加载失败" + ); + } + + function init() { + wireAuth(); + ready(function () { + applyContextHash(); + markHydrationLoading(); + const work = (async function () { + hydrateShellIdentity(); + if (page === "index.html") await loadLiveDashboard(); + if (page === "products.html") { + wireProductCreate(); + await loadLiveProducts(); + } + if (page === "product-detail.html") await hydrateProductDetail(); + if (page === "projects.html") await loadLiveProjects(); + if (page === "projects-new.html") { + wireProjectWizard(); + await loadLiveWizardProducts(); + } + if (page === "library.html") { + wireLiveAssets(); + await loadLiveAssets(); + } + if (page === "account.html") { + wireLiveAccount(); + await loadLiveAccount(); + } + if (page === "settings.html") { + wireLiveSettings(); + await loadLiveSettings(); + } + if (page === "team.html") { + wireLiveTeam(); + await loadLiveTeam(); + } + if (page === "messages.html") { + wireLiveMessages(); + await loadLiveMessages(); + } + if (page === "pipeline.html") await loadLivePipeline(); + })(); + + work + .then(function () { + applyContextHash(); + markHydrationDone(); + }) + .catch(function (error) { + markHydrationError(error && error.message); + }); + }); + } + + window.AirShelfBridge = { + version: BRIDGE_VERSION, + api: api, + isLive: isLive, + canUseApi: canUseApi, + loadLiveProducts: loadLiveProducts, + loadLiveProjects: loadLiveProjects, + loadLiveDashboard: loadLiveDashboard, + loadLiveSettings: loadLiveSettings, + loadLiveMessages: loadLiveMessages, + }; + + init(); +})(); diff --git a/core/frontend/public/exact/assets/icons.js b/core/frontend/public/exact/assets/icons.js new file mode 100644 index 0000000..eda9f3d --- /dev/null +++ b/core/frontend/public/exact/assets/icons.js @@ -0,0 +1,94 @@ +(function () { + const PATHS = { + home: '', + layoutDashboard: '', + package: '', + boxes: '', + clapperboard: '', + film: '', + video: '', + sparkles: '', + images: '', + folder: '', + library: '', + users: '', + wallet: '', + creditCard: '', + settings: '', + airshelf: '', + flame: '', + search: '', + bell: '', + list: '', + chevronLeft: '', + chevronRight: '', + check: '', + x: '', + plus: '', + productPlus: '', + arrowRight: '', + arrowLeft: '', + arrowUp: '', + chevronDown: '', + rotateCcw: '', + download: '', + upload: '', + moreHorizontal: '', + trash: '', + edit: '', + play: '', + clock: '', + alertCircle: '', + info: '', + shieldCheck: '', + messageCircle: '', + mail: '', + lock: '', + eye: '', + image: '', + grid: '', + copy: '', + save: '', + userPlus: '', + helpCircle: '' + }; + + const ALIASES = { + dashboard: 'layoutDashboard', + products: 'package', + projects: 'clapperboard', + assetFactory: 'sparkles', + library: 'folder', + account: 'creditCard', + billing: 'creditCard', + team: 'users', + queue: 'list', + arrow: 'arrowRight', + back: 'arrowLeft', + close: 'x', + rerun: 'rotateCcw', + more: 'moreHorizontal', + danger: 'alertCircle' + }; + + function svg(name, opts) { + opts = opts || {}; + const key = ALIASES[name] || name; + const body = PATHS[key] || PATHS.helpCircle; + const size = opts.size || 16; + const strokeWidth = opts.strokeWidth || 1.5; + const className = ['ui-icon', opts.className || ''].join(' ').trim(); + const label = opts.label ? ` role="img" aria-label="${escapeAttr(opts.label)}"` : ' aria-hidden="true"'; + return `${body}`; + } + + function escapeAttr(value) { + return String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + window.IconKit = { + svg, + names: () => Object.keys(PATHS), + has: name => !!PATHS[ALIASES[name] || name] + }; +})(); diff --git a/core/frontend/public/exact/assets/logo-dark.png b/core/frontend/public/exact/assets/logo-dark.png new file mode 100644 index 0000000..42f1fdb Binary files /dev/null and b/core/frontend/public/exact/assets/logo-dark.png differ diff --git a/core/frontend/public/exact/assets/logo.png b/core/frontend/public/exact/assets/logo.png new file mode 100644 index 0000000..94121b1 Binary files /dev/null and b/core/frontend/public/exact/assets/logo.png differ diff --git a/core/frontend/public/exact/assets/mock-media.js b/core/frontend/public/exact/assets/mock-media.js new file mode 100644 index 0000000..2f3253c --- /dev/null +++ b/core/frontend/public/exact/assets/mock-media.js @@ -0,0 +1,152 @@ +(function () { + const base = 'assets/mock/'; + const media = { + products: { + mask: base + 'product-mask.png', + earbuds: base + 'product-earbuds.png', + noodle: base + 'product-noodle.png', + sunscreen: base + 'product-sunscreen.png', + coffee: base + 'product-coffee.png', + airFryer: base + 'product-air-fryer.png', + yoga: base + 'product-yoga-pants.png', + storage: base + 'product-storage.png' + }, + covers: { + mask: base + 'cover-mask-v3.png', + maskFinal: base + 'cover-mask-final.png', + noodle: base + 'cover-noodle.png', + sunscreen: base + 'cover-sunscreen.png', + coffee: base + 'cover-coffee.png', + earbuds: base + 'cover-earbuds.png', + yoga: base + 'cover-yoga.png', + airFryer: base + 'cover-air-fryer.png' + }, + people: { + linxi: base + 'person-linxi.png', + ajie: base + 'person-ajie.png', + aqiang: base + 'person-aqiang.png', + xiaosu: base + 'person-xiaosu.png' + }, + scenes: { + bedroom: base + 'scene-bedroom.png', + bathroom: base + 'scene-bathroom.png', + living: base + 'scene-living.png', + kitchen: base + 'scene-kitchen.png', + office: base + 'scene-office.png', + cafe: base + 'scene-cafe.png', + street: base + 'scene-night-street.png', + tabletop: base + 'scene-tabletop.png' + } + }; + + function clean(text) { + return String(text || '').replace(/\s+/g, '').toLowerCase(); + } + + function contextText(el) { + const card = el.closest('[data-name], [data-product], [data-project], [data-cat], [data-asset-kind], [data-scene-type]'); + const bits = [ + el.textContent, + card?.dataset.name, + card?.dataset.product, + card?.dataset.project, + card?.dataset.cat, + card?.dataset.assetKind, + card?.dataset.sceneType, + card?.querySelector('.product-name, .card-name, .asset-name, .prod-name, strong')?.textContent + ]; + return clean(bits.filter(Boolean).join(' ')); + } + + function productFor(t) { + if (/蓝牙|耳机|earbud|南卡/.test(t)) return media.products.earbuds; + if (/速食|牛肉面|泡面|面条|noodle/.test(t)) return media.products.noodle; + if (/防晒|sunscreen/.test(t)) return media.products.sunscreen; + if (/咖啡|冻干|coffee/.test(t)) return media.products.coffee; + if (/空气炸锅|airfryer|小熊/.test(t)) return media.products.airFryer; + if (/瑜伽裤|露露|yoga/.test(t)) return media.products.yoga; + if (/收纳|storage|北欧/.test(t)) return media.products.storage; + if (/面膜|补水|玻尿酸|mask|透真/.test(t)) return media.products.mask; + return ''; + } + + function coverFor(t) { + if (/蓝牙|耳机|earbud|南卡/.test(t)) return media.covers.earbuds; + if (/速食|牛肉面|泡面|面条|noodle/.test(t)) return media.covers.noodle; + if (/防晒|sunscreen/.test(t)) return media.covers.sunscreen; + if (/咖啡|冻干|coffee/.test(t)) return media.covers.coffee; + if (/空气炸锅|airfryer|小熊/.test(t)) return media.covers.airFryer; + if (/瑜伽裤|露露|yoga/.test(t)) return media.covers.yoga; + if (/v1|final|已完成|5\/5|成片|敷面膜|化妆台/.test(t)) return media.covers.maskFinal; + if (/面膜|补水|玻尿酸|mask|透真|场1|场2|场3/.test(t)) return media.covers.mask; + return ''; + } + + function personFor(t) { + if (/阿杰|通勤男|男青年/.test(t)) return media.people.ajie; + if (/阿强|健身男|健身/.test(t)) return media.people.aqiang; + if (/小苏|文艺女|短发|阿楠|同事|小七|学生女|闺蜜|妈妈|王姐|豆豆/.test(t)) return media.people.xiaosu; + if (/林夕|主播|都市白领|主角|女性|女生/.test(t)) return media.people.linxi; + if (/小宇|李爷爷|男性|男/.test(t)) return media.people.ajie; + return ''; + } + + function sceneFor(t) { + if (/卧室|床头|bedroom/.test(t)) return media.scenes.bedroom; + if (/浴室|梳妆台|bathroom/.test(t)) return media.scenes.bathroom; + if (/客厅|living/.test(t)) return media.scenes.living; + if (/厨房|中岛|kitchen/.test(t)) return media.scenes.kitchen; + if (/办公室|办公桌|会议室|office|深夜办公/.test(t)) return media.scenes.office; + if (/咖啡店|窗边|cafe/.test(t)) return media.scenes.cafe; + if (/街景|夜|street/.test(t)) return media.scenes.street; + if (/平台|套图|布景|tabletop/.test(t)) return media.scenes.tabletop; + return ''; + } + + function imageFor(el) { + if (el.classList.contains('missing') || el.querySelector('.spinner, .fail-icon')) return ''; + const t = contextText(el); + const card = el.closest('.asset-card, .asset-card-2, .proj-card, .product-card, .video-card'); + if (el.id === 'ed-canvas') return media.covers.maskFinal; + if (el.id === 'sb-main-img' || el.id === 'vd-main-img') return media.covers.mask; + if (el.matches('.video-thumb')) { + if (card?.dataset.videoId === 'v2') return media.products.mask; + if (card?.dataset.videoId === 'v3') return media.covers.maskFinal; + } + if (el.matches('.card-thumb, .proj-thumb, .video-thumb') || card?.classList.contains('video')) return coverFor(t); + if (el.matches('.product-thumb, .prod-thumb, .pl-thumb')) return productFor(t); + if (el.matches('.m-thumb')) return personFor(t); + if (el.matches('.thumb-2, .asset-thumb')) { + return personFor(t) || sceneFor(t) || productFor(t) || coverFor(t); + } + if (el.matches('.sb-scene-thumb, .sb-history-thumb, .vd-history-thumb')) return coverFor(t) || media.covers.mask; + return productFor(t) || personFor(t) || sceneFor(t) || coverFor(t); + } + + function applyOne(el) { + if (!el || el.dataset.mockMediaApplied === '1') return; + const src = imageFor(el); + if (!src) return; + el.dataset.mockMediaApplied = '1'; + el.classList.add('has-mock-media'); + el.style.setProperty('--mock-media-url', `url("${src}")`); + el.style.backgroundImage = `url("${src}")`; + } + + function apply() { + document.querySelectorAll('.placeholder, #ed-canvas').forEach(applyOne); + } + + function boot() { + apply(); + const mo = new MutationObserver(() => apply()); + mo.observe(document.body, { childList: true, subtree: true }); + window.MockMedia = { apply, media }; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); + } else { + boot(); + } +})(); diff --git a/core/frontend/public/exact/assets/mock/cover-air-fryer.png b/core/frontend/public/exact/assets/mock/cover-air-fryer.png new file mode 100644 index 0000000..ba8ba94 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-air-fryer.png differ diff --git a/core/frontend/public/exact/assets/mock/cover-coffee.png b/core/frontend/public/exact/assets/mock/cover-coffee.png new file mode 100644 index 0000000..c21d895 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-coffee.png differ diff --git a/core/frontend/public/exact/assets/mock/cover-earbuds.png b/core/frontend/public/exact/assets/mock/cover-earbuds.png new file mode 100644 index 0000000..3c11af7 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-earbuds.png differ diff --git a/core/frontend/public/exact/assets/mock/cover-mask-final.png b/core/frontend/public/exact/assets/mock/cover-mask-final.png new file mode 100644 index 0000000..e44cf5b Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-mask-final.png differ diff --git a/core/frontend/public/exact/assets/mock/cover-mask-v3.png b/core/frontend/public/exact/assets/mock/cover-mask-v3.png new file mode 100644 index 0000000..f309950 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-mask-v3.png differ diff --git a/core/frontend/public/exact/assets/mock/cover-noodle.png b/core/frontend/public/exact/assets/mock/cover-noodle.png new file mode 100644 index 0000000..b57055f Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-noodle.png differ diff --git a/core/frontend/public/exact/assets/mock/cover-sunscreen.png b/core/frontend/public/exact/assets/mock/cover-sunscreen.png new file mode 100644 index 0000000..43e9d6c Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-sunscreen.png differ diff --git a/core/frontend/public/exact/assets/mock/cover-yoga.png b/core/frontend/public/exact/assets/mock/cover-yoga.png new file mode 100644 index 0000000..1e7ee75 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-yoga.png differ diff --git a/core/frontend/public/exact/assets/mock/person-ajie.png b/core/frontend/public/exact/assets/mock/person-ajie.png new file mode 100644 index 0000000..8350809 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-ajie.png differ diff --git a/core/frontend/public/exact/assets/mock/person-aqiang.png b/core/frontend/public/exact/assets/mock/person-aqiang.png new file mode 100644 index 0000000..10d84af Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-aqiang.png differ diff --git a/core/frontend/public/exact/assets/mock/person-linxi.png b/core/frontend/public/exact/assets/mock/person-linxi.png new file mode 100644 index 0000000..6b1a056 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-linxi.png differ diff --git a/core/frontend/public/exact/assets/mock/person-xiaosu.png b/core/frontend/public/exact/assets/mock/person-xiaosu.png new file mode 100644 index 0000000..d27adfd Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-xiaosu.png differ diff --git a/core/frontend/public/exact/assets/mock/product-air-fryer.png b/core/frontend/public/exact/assets/mock/product-air-fryer.png new file mode 100644 index 0000000..2676fa5 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-air-fryer.png differ diff --git a/core/frontend/public/exact/assets/mock/product-coffee.png b/core/frontend/public/exact/assets/mock/product-coffee.png new file mode 100644 index 0000000..8ea2166 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-coffee.png differ diff --git a/core/frontend/public/exact/assets/mock/product-earbuds.png b/core/frontend/public/exact/assets/mock/product-earbuds.png new file mode 100644 index 0000000..16f4cb0 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-earbuds.png differ diff --git a/core/frontend/public/exact/assets/mock/product-mask.png b/core/frontend/public/exact/assets/mock/product-mask.png new file mode 100644 index 0000000..66fb12a Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-mask.png differ diff --git a/core/frontend/public/exact/assets/mock/product-noodle.png b/core/frontend/public/exact/assets/mock/product-noodle.png new file mode 100644 index 0000000..742130f Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-noodle.png differ diff --git a/core/frontend/public/exact/assets/mock/product-storage.png b/core/frontend/public/exact/assets/mock/product-storage.png new file mode 100644 index 0000000..c97c18c Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-storage.png differ diff --git a/core/frontend/public/exact/assets/mock/product-sunscreen.png b/core/frontend/public/exact/assets/mock/product-sunscreen.png new file mode 100644 index 0000000..fa3af5e Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-sunscreen.png differ diff --git a/core/frontend/public/exact/assets/mock/product-yoga-pants.png b/core/frontend/public/exact/assets/mock/product-yoga-pants.png new file mode 100644 index 0000000..14550bd Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-yoga-pants.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-bathroom.png b/core/frontend/public/exact/assets/mock/scene-bathroom.png new file mode 100644 index 0000000..db3a459 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-bathroom.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-bedroom.png b/core/frontend/public/exact/assets/mock/scene-bedroom.png new file mode 100644 index 0000000..cd4c4df Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-bedroom.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-cafe.png b/core/frontend/public/exact/assets/mock/scene-cafe.png new file mode 100644 index 0000000..ab8e5dc Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-cafe.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-kitchen.png b/core/frontend/public/exact/assets/mock/scene-kitchen.png new file mode 100644 index 0000000..755d007 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-kitchen.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-living.png b/core/frontend/public/exact/assets/mock/scene-living.png new file mode 100644 index 0000000..8c32db2 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-living.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-night-street.png b/core/frontend/public/exact/assets/mock/scene-night-street.png new file mode 100644 index 0000000..fe96358 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-night-street.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-office.png b/core/frontend/public/exact/assets/mock/scene-office.png new file mode 100644 index 0000000..1857731 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-office.png differ diff --git a/core/frontend/public/exact/assets/mock/scene-tabletop.png b/core/frontend/public/exact/assets/mock/scene-tabletop.png new file mode 100644 index 0000000..790a739 Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-tabletop.png differ diff --git a/core/frontend/public/exact/assets/new-product-drawer.js b/core/frontend/public/exact/assets/new-product-drawer.js new file mode 100644 index 0000000..115f976 --- /dev/null +++ b/core/frontend/public/exact/assets/new-product-drawer.js @@ -0,0 +1,784 @@ +/* ============================================================ + 新建商品 · 共享 Drawer 模块 + ---------------------------------------------------------- + 在任意页面只需 + + diff --git a/core/frontend/public/exact/design.md b/core/frontend/public/exact/design.md new file mode 100644 index 0000000..c5b5288 --- /dev/null +++ b/core/frontend/public/exact/design.md @@ -0,0 +1,1151 @@ +# Airshelf 设计规范 · design.md + +> **唯一权威 source of truth** · 所有页面调整必须遵循本文件。 +> **代号:** Restraint(克制)· V2.1 · Firecrawl-aligned +> **维护日期:** 2026-05-22 +> **配套实现:** [assets/restraint.css](assets/restraint.css) (token + 100+ 组件类 · 1592 行) +> **可视样板间(归档):** [_archive/design-system.html](_archive/design-system.html) +> **历史规范(归档):** [_archive/DESIGN_SPEC_V2.md](_archive/DESIGN_SPEC_V2.md) + +--- + +## §0 · AI 协作铁律(每次启动必读) + +**Claude / 任何 AI 在做页面或 CSS 调整前,必须执行以下流程:** + +1. **先 Read 本文件** · 至少读 §1 设计哲学 + §3 组件清单 + §8 Don't List +2. **检查 restraint.css 是否已有该组件** · 用 `Grep "\.btn|\.pill|\.input" assets/restraint.css` 查现成类名 +3. **禁止在页面 inline ` + + +
+
+ + + + + +
+ +
+ + + + +
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ + + + +
+
+ +
+
+ + +
+ + + + + +
+ + + +
+ 比例 + 1:1 + +
+
+ +
+ 风格 + 默认 + +
+
+ +
+ 张数 + 4 + +
+
+ + 预估 ¥0.40 · 余额 ¥327.40 + +
+
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/core/frontend/public/exact/index.html b/core/frontend/public/exact/index.html new file mode 100644 index 0000000..f1d6fa5 --- /dev/null +++ b/core/frontend/public/exact/index.html @@ -0,0 +1,183 @@ + + + + +工作台 · Airshelf + + + + + + + + + + + + + + diff --git a/core/frontend/public/exact/library.html b/core/frontend/public/exact/library.html new file mode 100644 index 0000000..7d3fcad --- /dev/null +++ b/core/frontend/public/exact/library.html @@ -0,0 +1,2484 @@ + + + + +资产库 · Airshelf + + + + + +
+ +
+
+

资产库

+
// 跨项目复用 · 0 人 · 0 景 · 0 商 · 0
+
+
+ + +
+
+ +
+
人物 0
+
场景 0
+
商品图 0
+
成片 0
+
我的上传 0
+
未分类 0
+
+ +
+
+ + +
+ + +
+ +
+
+
+ +
+
+
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + + + + +
+ +
+
+
+ +
// 显示 0 / 0 个资产
+ + +
+
+ + +
林夕 · 都市白领
+
+
林夕
+
女 · 青年 · 都市白领 · 用过 4 次
+
+
+
+ + +
阿楠 · 同事女
+
+
阿楠
+
女 · 青年 · 都市白领 · 用过 2 次
+
+
+
+ + +
小七 · 学生女
+
+
小七
+
女 · 青年 · 学生 · 用过 3 次
+
+
+
+ + +
阿杰 · 通勤男
+
+
阿杰
+
男 · 青年 · 都市白领 · 用过 2 次
+
+
+
+ + +
+ + + 缺三视图 + + + + MISSING TRI-VIEW + + 手动上传的人物未生成 正 / 侧 / 背 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。 + 建议:前往 图片生成 先补齐三视图,再发起后续生成。 + + + 妈妈 · 居家 +
+
+
妈妈 · 王姐
+
女 · 中年 · 居家 · 用过 1 次
+
+
+
+ + +
阿强 · 健身男
+
+
阿强
+
男 · 青年 · 健身 · 用过 2 次
+
+
+
+ + +
小苏 · 文艺女
+
+
小苏
+
女 · 青年 · 文艺 · 用过 1 次
+
+
+
+ + +
闺蜜组合 · 双人
+
+
闺蜜组合
+
女 · 青年 · 都市白领 · 用过 1 次
+
+
+
+ + +
豆豆 · 幼儿
+
+
豆豆
+
女 · 幼年 · 居家 · 用过 2 次
+
+
+
+ + +
小宇 · 中学生
+
+
小宇
+
男 · 少年 · 学生 · 用过 1 次
+
+
+
+ + +
+ + + 缺三视图 + + + + MISSING TRI-VIEW + + 手动上传的人物未生成 正 / 侧 / 背 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。 + 建议:前往 图片生成 先补齐三视图,再发起后续生成。 + + + 李爷爷 · 居家 +
+
+
李爷爷
+
男 · 老年 · 居家 · 用过 1 次
+
+
+
+ + + + + + + + + + + + + + + + +
+
+ +
+

没有匹配的资产

+

// 试试切换 tab 或修改搜索词

+
+ + + + + + + +
+ + + + + +
+ 已选 0 + + + +
+ +
+
+ +
+ + + + + + + diff --git a/core/frontend/public/exact/login.html b/core/frontend/public/exact/login.html new file mode 100644 index 0000000..4cca33d --- /dev/null +++ b/core/frontend/public/exact/login.html @@ -0,0 +1,220 @@ + + + + +登录 · Airshelf + + + + + +← 返回工作台 + +
+ + + + + + +
+
+

登录

+ // /auth/login +
+

使用团队邀请邮箱登录,接受邀请后自动加入对应团队。

+ +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ + 忘记密码? +
+ + + +
OR
+ +
+ + +
+ +
+ 还没账号? 注册团队 → +
+
+
+
+ + + + + diff --git a/core/frontend/public/exact/messages.html b/core/frontend/public/exact/messages.html new file mode 100644 index 0000000..524429f --- /dev/null +++ b/core/frontend/public/exact/messages.html @@ -0,0 +1,743 @@ + + + + +消息中心 · Airshelf + + + + + +
+
+
+
+

消息中心

+
// INBOX 任务提醒 · 团队协作 · 计费与系统公告
+
+
+ + +
+
+ +
+
+
+ 收件箱 + // 0 条 +
+
+ +
+
+ +
+
+ +
+ // 消息保留 90 天 · 高风险任务会同时进入工作台队列 + 管理通知策略 → +
+
+
+ + + + + + + diff --git a/core/frontend/public/exact/model-photo-demo-a.html b/core/frontend/public/exact/model-photo-demo-a.html new file mode 100644 index 0000000..ac791f5 --- /dev/null +++ b/core/frontend/public/exact/model-photo-demo-a.html @@ -0,0 +1,666 @@ + + + + +方案 A · 商品空间 · 模特上身图 · Airshelf + + + + + +
+ +
// DEMO · 方案 A · 商品 = 项目空间(Q1: A+B)。左栏仅商品空间:🔍 搜索 + 最近 6 条 + 全部商品兜底入口;历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。
+ +
+ + + + + +
+ + +
+
+
// 商品空间
+

透真补水面膜

+
+
+ 本商品 6 + · + 累计 22 张图 + · + 最近 3 分钟前 +
+
+ + +
+ + +
+
+ +
+
选择模特(已锁定商品 · 透真补水面膜)
+
+
+
Ava · 3:4
+
Ava
+
+
+
Zoe · 3:4
+
Zoe
+
+
+
Ben · 3:4
+
Ben
+
+
+
Lin · 3:4
+
Lin
+
+
+
Mia · 3:4
+
Mia
+
+
+ + +
+
+
+ +
+
生成张数
+
+ + + + +
+
+ +
+
画面比例
+
+ + + + +
+
+ +
+
补充提示词(选填)
+ +
+ +
+ +
+
+ 预估扣费 ≈ ¥1.20 + 余额 ¥327.40 +
+ +
+
+ + +
+
+ 最近批次 · Ava × 4 张 + // 3 分钟前 · 已完成 +
+ + +
+
+
+
+
Ava × 4 张
+
透真补水面膜 · 3:4 · 3 分钟前 · ¥1.20
+
+
+ + + +
+
+
+
Ava · #1
3:4
+
Ava · #2
3:4
+
Ava · #3
3:4
+
Ava · #4
3:4
+
+
+ + +
+
+
+
+
Zoe × 4 张
+
透真补水面膜 · 3:4 · 12 分钟前 · ¥1.20
+
+
+ + + +
+
+
+
Zoe · #1
3:4
+
Zoe · #2
3:4
+
Zoe · #3
3:4
+
Zoe · #4
3:4
+
+
+ + +
+
+
+
+
Ben × 2 张
+
透真补水面膜 · 3:4 · 刚刚 · 生成中
+
+
+ +
+
+
+
生成中…
+
生成中…
+
+
+ +
+
+ +
+ +
+ +
+ + + + + + + diff --git a/core/frontend/public/exact/model-photo-demo-b.html b/core/frontend/public/exact/model-photo-demo-b.html new file mode 100644 index 0000000..5b5169e --- /dev/null +++ b/core/frontend/public/exact/model-photo-demo-b.html @@ -0,0 +1,657 @@ + + + + +方案 A · v2 · 模特上身图 · Airshelf + + + + + +
+ +
// DEMO v2 · 方案 A · 商品空间(A+B) + 任务流主区。左栏只保留商品空间(搜索+最近6条+全部入口),任务列表搬到主区,筛选放主区顶部 toolbar,参数面板底部 fixed 化(类 image-optimize)。
+ +
+ + + + + +
+ + +
+
// 商品空间 · 模特上身图
+

透真补水面膜

+
+
+ 美妆个护· + 本商品 6· + 累计 22 张图· + 最近 3 分钟前 +
+
+
+ + + + +
+
+
+ + +
+ +
+ 今天 + 3 批 · 10 张 +
+ + +
+
+
+
+
Ava × 4 张 已完成
+
+ 3:4· + 3 分钟前· + ¥1.20 +
+
+
+ + + +
+
+
+
Ava · #1
3:4
+
Ava · #2
3:4
+
Ava · #3
3:4
+
Ava · #4
3:4
+
+
+ + +
+
+
+
+
Zoe × 4 张 已完成
+
+ 3:4· + 12 分钟前· + ¥1.20 +
+
+
+ + + +
+
+
+
Zoe · #1
3:4
+
Zoe · #2
3:4
+
Zoe · #3
3:4
+
Zoe · #4
3:4
+
+
+ + +
+
+
+
+
Ben × 2 张 生成中
+
+ 3:4· + 刚刚· + ¥0.60 +
+
+
+ +
+
+
+
生成中…
+
生成中…
+
+
+ + +
+ 昨天 + 2 批 · 8 张 +
+ +
+
+
+
+
Lin × 4 张 已完成
+
+ 3:4· + 昨天 18:24· + ¥1.20 +
+
+
+ + + +
+
+
+
Lin · #1
3:4
+
Lin · #2
3:4
+
Lin · #3
3:4
+
Lin · #4
3:4
+
+
+ + +
+ 更早 + 1 批 · 2 张 · 含 1 失败 +
+ +
+
+
+
+
Ava × 2 张 失败
+
+ 3:4· + 2 天前 +
+
+
+ + +
+
+
+
失败 · 点重跑
+
失败 · 点重跑
+
+
+ +
+ + +
+
+ + + + + + 预估 ¥1.20 · 余额 ¥327.40 + +
+
+ +
+ +
+ +
+ + + + + + + diff --git a/core/frontend/public/exact/model-photo.html b/core/frontend/public/exact/model-photo.html new file mode 100644 index 0000000..cd93557 --- /dev/null +++ b/core/frontend/public/exact/model-photo.html @@ -0,0 +1,4799 @@ + + + + +模特上身图 · Airshelf + + + + + +
+ +
+ + + + + +
+ + +
+ +
+ // 商品空间 + 未选择 · 请在左侧商品空间选一个 +
+ +
+ + +
+
+ +
+ + + + +
+
+
+ +
+ +
暂无批次,生成后可按模特筛选
+
+
+
+ +
+ + +
+ + +
+
+ 1 + 选择模特 + 全部模特 → +
+
+
+
+
Ava
+
Ava
+
亚洲·25岁·清新
+
+
+
+
Luna
+
Luna
+
亚洲·22岁·学生
+
+
+
+
Mia
+
Mia
+
混血·28岁·OL
+
+
+
+
Zoe
+
Zoe
+
亚洲·30岁·健身
+
+
+
+ + +
+
+ 2 + 生成设置 +
+
+
// 生成数量 (每模特)
+
+ + + +
+
+
+
// 图片比例
+
+ + + +
+
+
+ + +
+ +
// 采用即扣费并入对应商品 AI 素材 · 未采用不扣
+
+ +
+ + +
+ +
+
// EMPTY STATE
+
还没有生成结果
+
先选商品、选模特,点击 立即生成 后,效果图会出现在这里
+
+ + + + +
+ +
+
+
待生成 · 1:1
+
待生成 · 1:1
+
待生成 · 1:1
+
待生成 · 1:1
+
+
+
+ +
+ // 采用即扣费并入对应商品的 AI 素材库 →;未采用的图不扣费、不保存 +
// 切换左侧商品空间 · 查看其他商品的批次记录 +
+
+ +
+
+ +
+
+ + +
+
+
+

商品库

+ // 共 7 个商品 +
+ +
+
+
+ +
+
+ + +
+
+
+ +
+
+
+
+
+
// 已选 0 个商品
+ + +
+
+
+ + +
+ + + +
+
+
+

模特库

+ +
+
+ +
+
+
+ 性别 + + + +
+
+ 年龄 + + + +
+
+
+
+ +
+
+ + + +
+
+
+
+ + +
+ +
+ + + +
+
+
+

模特详情

+ / 人物 · 模特 + +
+
+
+ +
+
+
+
+
立绘 · 3:4
+
+ +
+
+
+ +
+
+
+ + 三视图 + 16:9 + +
+
+
正 / 侧 / 背 · 三视图
+
+
+
+
+ + 简介 +
+

+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + diff --git a/core/frontend/public/exact/nginx.conf b/core/frontend/public/exact/nginx.conf new file mode 100644 index 0000000..c2fd5f0 --- /dev/null +++ b/core/frontend/public/exact/nginx.conf @@ -0,0 +1,41 @@ +server_tokens off; +charset utf-8; + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + # 设计稿迭代期:CSS/JS 不带 hash,不能用 immutable,否则改了浏览器仍吃旧版 + # 缓存 1 小时 + must-revalidate(过期后用 If-Modified-Since 回源验证,服务器没改返回 304,改了取新文件) + location ~* \.(css|js)$ { + expires 1h; + add_header Cache-Control "public, must-revalidate"; + } + + # 图片/字体/媒体:可以缓存稍长(改动频率低),仍允许重新验证 + location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp4|webm)$ { + expires 7d; + add_header Cache-Control "public, must-revalidate"; + } + + # HTML:不进浏览器磁盘缓存,每次都问服务器有没有新版本 + location ~* \.html$ { + add_header Cache-Control "no-cache, must-revalidate" always; + expires off; + } + + # Multi-page (not SPA): try exact file, then .html fallback (friendly URLs), then 404. + # NEVER fallback to index.html — that masks 404s for a multi-entry design shelf. + location / { + try_files $uri $uri/ $uri.html =404; + } +} diff --git a/core/frontend/public/exact/pipeline.html b/core/frontend/public/exact/pipeline.html new file mode 100644 index 0000000..70caab9 --- /dev/null +++ b/core/frontend/public/exact/pipeline.html @@ -0,0 +1,5752 @@ + + + + +流水线 · Airshelf + + + + + +
+ + + + + + +
+
+
+
+
+ 镜头脚本 + · 空 · 待生成 +
+
+ 来源未选择 + 风格待确认 + 人物待确认 +
+
+
+ // 人物 + +
+
+ // 场景 + +
+
+ + +
+
+ +
+
+ + + +
+
+
AI
+ 脚本助手 + · GPT-4o + + +
+
+ +
+
+
+ + +
+ + + +
+
+ +
+
+
+ +
+
[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]
+
+ + +
+
+
+ + +
+
+
+
商品3 张
+
人物2/2
+
场景3/3
+
+ 基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。 +

+ // 人物 +¥0.20/张 + // 场景 +¥0.15/张 + 商品图无成本(直接复用商品库) +
+
+ +
+ +
+
+

商品 · 透真补水面膜

+ +
+
+
+
+ + + 缺三视图 + + + + MISSING TRI-VIEW + + 该商品还未生成 正 / 侧 / 背 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。 + 建议:点右下 AI 生成三视图 先补齐三视图,再发起后续生成。 + + + 透真补水面膜 · 主图 +
+
+
透真补水面膜
+
美妆个护
+
2026-05-15 创建
+
+
+ +
+
+
+
// 三视图预览 · 生成中
+
+
+
+
// 历史版本 · 0
+
+
+
+
+
+ + +
+
+

人物 · 2 个

+ + +
+ +
+
+
林夕 · 都市白领
+
+
主角 · 林夕
+
25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。
+
+ + + +
+
+
+
+
+
+
+ 生成中 · 约 8s +
+
+
+
朋友/同事 · 阿楠
+
25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。
+
+ + + +
+
+
+
+ +
+ + +
+
+

场景 · 3 个

+ + +
+ +
+
+
深夜办公桌
+
+
深夜办公桌
+
深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。
+
+ + + +
+
+
+
+
床头特写
+
+
卧室床头
+
米白色床品,木质床头柜,闹钟显示晚间时间。
+
+ + + +
+
+
+
+
+
+
!
+ 生成失败 +
+
+
+
通勤地铁失败
+
早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。
+
+ + + +
+
+
+
+ +
+
+
+ +
+
[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]
+
+ + +
+
+
+ + +
+
+
+
+ +
+
未选择
+
+ +
+
+
+ 故事板 · 场 1 + + 已生成 +
+
+ 整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。 +
+
+ +
+ 仅支持整张重跑 · 不能局部改某一镜。如需调单镜,先在 Stage 1 脚本 改镜头描述,再回此处整张重跑。 +
+
+ +
// 本场提示词
+
+ +
+ + + ~¥0.45/场 +
+ +
+
// 历史版本(0)
+
+
// 暂无历史版本
+
+
+ +
+ +
// 绑定的资产
+
+ 林夕(人物) + 深夜办公桌(场景) +
+
+
+
+ +
+
[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]
+
+ + +
+
+
+ + +
+
+
+
视频生成 · 3 / 3 完成
+
// 每场 Seedance 约 15 秒 · 已完成所有场次
+
+
+ 100% + + + +
+ +
+
+
+ 场 1 · 0-15s +
+
+
+
场 1 · 深夜办公桌完成
+
15s · 1080×1920 · ¥0.45
+
+ + + +
+
+
+
+
+ 场 2 · 15-27s +
+
+
+
场 2 · 面膜包装/特写完成
+
12s · 1080×1920 · ¥0.45
+
+ + + +
+
+
+
+
+ 场 3 · 27-40s +
+
+
+
场 3 · 化妆台/产品定格完成
+
13s · 1080×1920 · ¥0.45
+
+ + + +
+
+
+
+ +
+
[ 已完成 3 场 · 累计 ¥1.35 · 总时长 40s · 失败不扣 · 通过后扣 ]
+
+ + +
+
+
+ + +
+
+
+

视频详情

+ // 场 1 · 15s + +
+
+
+
+
大视频预览
+
+
+
// 基础信息
+
+ +
+
// 历史版本 · 3
+
+
+ +
+
+ // 视频提示词 +
+
+
+
+
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+
9:16 预览 · 1080×1920
+
+ + + + 00:00.00 / 00:15.00 +
+
+ +
+
+
字幕
+
转场
+
BGM
+
+
// 字幕样式
+
+
真实分享
朴素白底
+
真实分享
影视黑底
+
真实分享
手写描边
+
真实分享
综艺暖黄
+
+ +
+ +
// 当前选中(未选)
+
起始
+
时长
+
音量
+
速度
+
入场交叉淡化
+ +
+ +
// BGM
+
+ 温柔治愈钢琴 · 0:42 + +
+
+ +
+
+ + + + + + + +
+ // zoom + +
+
+ +
+
// time
+
+ 0s + + 2s + + 4s + + 6s + + 8s + + 10s + + 12s + + 14s + 15s +
+
+ +
+
+ + 视频 +
+
+
1深夜办公桌
+
2面膜包装
+
3精华液微距
+
4敷面膜平躺
+
5化妆台
+
6产品定格
+
+
+ +
+
+ + 字幕 +
+
+
加班三天 脸已经不能看了…
+
还好我有这个 透真玻尿酸面膜
+
30g 精华 一片顶三片
+
敷完起来脸是软的
+
化妆都能看出来
+
5 片 ¥39.9 囤起来
+
+
+
+ +
+
+ + BGM +
+
+
+ + 温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出) +
+
+
+
+
+ +
+
[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]
+
+ + + +
+
+
+ + +
+
+
+

资产详情

+ / kind + +
+
+
+ +
+
+
立绘
+ +
+
+
+ +
+ +
+
+ + 三视图 + 16:9 + +
+
+
正 / 侧 / 背 · 三视图
+
+ +
+ +
+
+ + 简介 +
+

+
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+ + +
+
+
+

新增人物

+ // 立绘必填 + 三视图(可 AI 生成) + +
+
+
+ + +
+
+
+
// 立绘*
+
+ + 点击上传立绘 + PNG / JPG · ≤10MB +
+
+
+
// 三视图(可选 · 16:9 单图)
+
+
正 / 侧 / 背 · 三视图
+
+
+ + 没有三视图?上传立绘后用 AI 自动生成 + +
+
+
+
+
+ + +
+
+
+ + +
+
+
+

演员库

+ // 共 0 个 + +
+
+ +
+
+ +
+
+
+ +
+
+ + + +
+
+
+
+ + + + + +
+ +
+ + + + + +
+ + + + + + diff --git a/core/frontend/public/exact/platform-cover.html b/core/frontend/public/exact/platform-cover.html new file mode 100644 index 0000000..ac29e53 --- /dev/null +++ b/core/frontend/public/exact/platform-cover.html @@ -0,0 +1,2357 @@ + + + + +平台套图 · Airshelf + + + + + +
+ +
+ + + + + +
+ + +
+ +
+ // 商品空间 + 未选择 · 请在左侧商品空间选一个 +
+ +
+ + +
+
+ +
+ + + + +
+
+
+ +
+ +
暂无批次,生成后可按平台筛选
+
+
+
+ +
+ + +
+ + +
+
+ 1 + 选择平台 +
+
+
+
+ +
抖音电商
+
+
+
+ +
淘宝
+
+
+
+ +
天猫
+
+
+
+ +
京东
+
+
+
+ +
拼多多
+
+
+
+ +
小红书
+
+
+
+ +
快手
+
+
+
+ +
视频号
+
+
+
+ +
亚马逊
+
+
+
+ +
1688
+
+
+
+ + +
+
+ 2 + 生成设置 +
+
+
// 生成数量
+
+ + + +
+
+
+ + +
+ +
// 满意后点 [入资产库] 才扣费 · 失败不扣
+
+ +
+ + +
+ +
+
// EMPTY STATE
+
还没有生成结果
+
先选商品、选平台,点击 立即生成 后,效果图会出现在这里
+
+ +
+ +
4 张
+
商品未选择
+
平台未选择
+
+
+ +
+ +
+ // 采用即扣费并入对应商品的 AI 素材库 →;未采用的图不扣费、不保存 +
// 切换左侧商品空间 · 查看其他商品的批次记录 +
+
+ +
+
+ +
+
+ + +
+ + + +
+
+
+

商品库

+ // 共 7 个商品 +
+ +
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
// 已选 0 个商品
+ + +
+
+
+ + + + + + + + + diff --git a/core/frontend/public/exact/product-create-upload.html b/core/frontend/public/exact/product-create-upload.html new file mode 100644 index 0000000..7634918 --- /dev/null +++ b/core/frontend/public/exact/product-create-upload.html @@ -0,0 +1,15 @@ + + + + +新建商品 · Airshelf + + + + + diff --git a/core/frontend/public/exact/product-create.html b/core/frontend/public/exact/product-create.html new file mode 100644 index 0000000..13b187a --- /dev/null +++ b/core/frontend/public/exact/product-create.html @@ -0,0 +1,55 @@ + + + + +新建商品 · Airshelf + + + + + + + + +
+
正在打开「新建商品」…
+
如未自动跳转,点击这里
+
+ + + + diff --git a/core/frontend/public/exact/product-detail.html b/core/frontend/public/exact/product-detail.html new file mode 100644 index 0000000..31e5188 --- /dev/null +++ b/core/frontend/public/exact/product-detail.html @@ -0,0 +1,2247 @@ + + + + + + + +商品详情 · Airshelf + + + + + +
+ + +
+

补水保湿精华液

+
+ + +
+ +
+
+ 商品信息 + +
+ + +
+ + + +
+ + + +
+
+ +
+ +
+
+
商品名称
+
+ 补水保湿精华液 + +
+
+
+
品类
+
+ 美妆个护 / 精华液 + +
+
+
+
目标人群
+
+ 22-32 岁女性、敏感肌、办公室通勤 + +
+
+
+
核心卖点
+
+
+ 透明质酸 + B5,敷完不黏不闷 + 30g 大容量精华液 + 0 香精 0 酒精,敏感肌可用 +
+
    + +
+
+
+
+ +
+
+ 商品图片 + (6) +
+
+
1:1
+
1:1
+
1:1
+
1:1
+
1:1
+
1:1
+
+ +
+
+
+ +
+
+ +
+
快速操作
+
+
// 图片生成
+
+
+ + 模特上身图 +
+
+ + 平台套图 +
+
+ + 图片创作 +
+
+
+
+
// 视频生成
+
+
+ + 生成视频 +
+
+
+
+ +
+ + +
+ + + +
+ + +
+ +
+
全部 AI 素材 (32)
+ + +
+
+ + +
+ +
+
+ +
+
模特上身图3:4
通过2026-05-19 15:30
+
模特上身图3:4
通过2026-05-19 15:30
+
模特上身图3:4
不通过2026-05-19 15:30
+
模特上身图3:4
通过2026-05-19 15:30
+
模特上身图3:4
归档2026-05-19 15:30
+
平台套图3:4
通过2026-05-19 15:30
+
平台套图3:4
通过2026-05-19 15:30
+
平台套图3:4
不通过2026-05-19 15:30
+
平台套图3:4
归档2026-05-19 15:30
+
平台套图3:4
通过2026-05-19 15:30
+
三视图3:4
通过2026-05-19 15:30
+
三视图3:4
归档2026-05-19 15:30
+
+ +
+
+ + +
+
+
该商品视频项目 (4)
+
+ +
+
+ +
+
视频 · 9:16补水面膜 · v3
已完成2026-05-20 12:08
+
视频 · 9:16补水面膜 · v2
视频生成 4/62026-05-19 10:24
+
视频 · 9:16熬夜急救 · v1
已归档2026-05-18 21:42
+
视频 · 9:16补水面膜 · v1
故事板失败2026-05-17 16:00
+
+ +
+
+ + +
+ + +
+
+
// TOTAL
+
12 个任务
+
+
+
// SUCCESS
+
9
+
+
+
// RUNNING
+
2
+
+
+
// FAILED
+
1
+
+
+ + +
+
任务记录 (12)
+ + + +
+ +
+
+ + +
+
+ + 任务 / 编号 + 数量 + 状态 + 提交时间 + 完成时间 + 耗时 + 操作 +
+ +
+
+
视频素材 // T-2026-0519-0007
+
1 个
+
+ 生成中 60% + +
+
2026-05-19 16:00
+
+
+
+ + +
+
+ +
+
+
模特上身图 // T-2026-0519-0006
+
3 张
+
+ 排队中 +
+
2026-05-19 15:58
+
+
+
+ + +
+
+ +
+
+
模特上身图 // T-2026-0519-0005
+
5 张
+
+ 已完成 +
+
2026-05-19 15:30
+
2026-05-19 15:32
+
2m 14s
+
+ +
+
+ +
+
+
平台套图 // T-2026-0519-0004
+
4 张
+
+ 已完成 +
+
2026-05-19 14:20
+
2026-05-19 14:23
+
3m 02s
+
+ +
+
+ +
+
+
模特上身图 // T-2026-0519-0003
+
4 张
+
+ 已完成 +
+
2026-05-19 13:10
+
2026-05-19 13:13
+
2m 50s
+
+ +
+
+ +
+
+
三视图 // T-2026-0519-0002
+
3 张
+
+ 失败 +
+
2026-05-19 12:00
+
2026-05-19 12:01
+
30s
+
+ + +
+
+ +
+
+
平台套图 // T-2026-0518-0001
+
6 张
+
+ 已完成 +
+
2026-05-18 18:42
+
2026-05-18 18:46
+
4m 10s
+
+ +
+
+ +
+ +
+
+ +
+ + + + + + + + + + diff --git a/core/frontend/public/exact/products.html b/core/frontend/public/exact/products.html new file mode 100644 index 0000000..654ef3b --- /dev/null +++ b/core/frontend/public/exact/products.html @@ -0,0 +1,1808 @@ + + + + +商品库 · Airshelf + + + + + +
+ +
+
+

商品库

+
// 0 SKU · 商品信息会作为脚本和资产生成的素材
+
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ +
+ +
+ // 显示 7 / 7 个商品 +
+ + +
+
+ + +
+
+
+ + +
补水面膜 · 1200×800
+
+
透真玻尿酸补水面膜
+
美妆个护
+
2026-05-15 创建
+
+ +
+ +
+ + +
蓝牙耳机 · 1200×800
+
+
南卡 Lite Pro 蓝牙耳机
+
数码 3C
+
2026-05-12 创建
+
+ +
+ +
+ + +
速食牛肉面 · 1200×800
+
+
滋啦速食牛肉面 · 6 桶装
+
食品饮料
+
2026-05-10 创建
+
+ +
+ +
+ + +
防晒霜 · 1200×800
+
+
透真清透物理防晒霜
+
美妆个护
+
2026-05-08 创建
+
+ +
+ +
+ + +
咖啡冻干粉 · 1200×800
+
+
三顿半同款冻干咖啡粉
+
食品饮料
+
2026-05-05 创建
+
+ +
+ +
+ + +
空气炸锅 · 1200×800
+
+
小熊 4L 可视空气炸锅
+
家居家电
+
2026-05-03 创建
+
+ +
+ +
+ + +
瑜伽裤 · 1200×800
+
+
露露同款裸感瑜伽裤
+
运动户外
+
2026-04-30 创建
+
+ +
+
+ +
+
+ +
+

没有匹配的商品

+

// 试试切换分类或修改搜索词

+
+
+ + + + +
+ +
+ + +
+ + + + + + + + + +
+ 已选 0 + + + + +
+ + + + + + + diff --git a/core/frontend/public/exact/projects-new.html b/core/frontend/public/exact/projects-new.html new file mode 100644 index 0000000..4278d4b --- /dev/null +++ b/core/frontend/public/exact/projects-new.html @@ -0,0 +1,1615 @@ + + + + +新建项目 · Airshelf + + + + + +
+ +
+
+

新建项目

+
// 商品 → 配置 · 2 步开始生成
+
+
+ 退出 +
+
+ +
+ +
+ +
+ +
+ + +
+
+
+

商品库

+ +
+ +
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
// 单选:点击商品即选用,自动关闭
+ +
+
+
+ + + + + + + + + + diff --git a/core/frontend/public/exact/projects.html b/core/frontend/public/exact/projects.html new file mode 100644 index 0000000..a2a144e --- /dev/null +++ b/core/frontend/public/exact/projects.html @@ -0,0 +1,1043 @@ + + + + +视频项目 · Airshelf + + + + + +
+ +
+
+

视频项目

+
// 0 个 · 0 进行中 · 0 完成 · 0 失败
+
+
+ + + + 新建项目 + +
+
+ +
+
全部 0
+
进行中 0
+
已完成 0
+
失败 0
+
+ +
+
+ + +
+
+ +
+
+
+ +
+
+
+ +
+
+ + +
+ + +
+
+ +
// 显示 12 / 12 个项目
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目商品脚本来源进度状态更新于
+
+
9:16
+
补水面膜 · 痛点种草 · v3
6 镜 · 0-15s
+
+
透真补水面膜AI 全生 +
+
+ 3/5 +
+
故事板生成中12 分钟前 +
+ +
+
+
+
+
9:16
+
速食牛肉面 · 加班治愈
4 镜 · 0-12s
+
+
滋啦速食 · 6 桶装一句话主题 +
+
+ 2/5 +
+
资产生成中37 分钟前
+
+
9:16
+
透真防晒 · 通勤对比
6 镜 · 0-18s
+
+
透真清透防晒霜AI 全生 +
+
+ 4/5 +
+
视频生成 4/62 小时前
+
+
9:16
+
咖啡冻干 · 早八剧情
5 镜 · 0-15s
+
+
三顿半同款冻干一句话主题 +
+
+ 3/5 +
+
故事板生成失败昨天 18:42
+
+
9:16
+
蓝牙耳机 · 开箱测评
5 镜 · 0-15s
+
+
南卡 Lite Pro自带脚本 +
+
+ 5/5 +
+
已完成5 月 7 日
+
+
9:16
+
瑜伽裤 · 通勤穿搭
5 镜 · 0-15s
+
+
露露同款瑜伽裤AI 全生 +
+
+ 5/5 +
+
已完成5 月 6 日
+
+
9:16
+
空气炸锅 · 小户型
4 镜 · 0-12s
+
+
小熊 4L 空气炸锅一句话主题 +
+
+ 5/5 +
+
已完成5 月 4 日
+
+ + + + + +
+
+ +
+

没有匹配的项目

+

// 试试切换 tab 或修改搜索词

+
+ + + + + +
+ 已选 0 + + + + +
+ +
+ + + + + + diff --git a/core/frontend/public/exact/register.html b/core/frontend/public/exact/register.html new file mode 100644 index 0000000..852d9e2 --- /dev/null +++ b/core/frontend/public/exact/register.html @@ -0,0 +1,248 @@ + + + + +注册团队 · Airshelf + + + + + +← 返回登录 + +
+ + + + +
+
+

注册团队

+ // /auth/register +
+

填写团队信息开通账户,默认成为团队超管。

+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ +
+ + +
+
+ + + + + +
+ 已有账号? 登录 → +
+
+
+
+ + + + + diff --git a/core/frontend/public/exact/settings.html b/core/frontend/public/exact/settings.html new file mode 100644 index 0000000..ace8169 --- /dev/null +++ b/core/frontend/public/exact/settings.html @@ -0,0 +1,855 @@ + + + + +设置 · Airshelf + + + + + +
+ +
+
+

设置

+
// 个人信息 · 偏好 · 通知 · 安全
+
+
+ + +
+
+ +
+ + + + +
+ +
+

个人信息

+
// 头像、姓名、联系方式 · 邮箱用于接收通知
+ +
+
头像
+
+
+
+
+ + +
+
+
+
+
+
显示名称*
+
+
+
+
登录邮箱
+
+ + +
+
+
+
手机号
+
+ + +
+
+
+
所属团队
// 一人一团队
+
+ 小李的店 + 超管 · 创建者 + 管理团队 → +
+
+
+
用户 ID
// 不可改
+
USR-2026-A8F2-001
+
+
+ + +
+

安全

+
// 登录密码、双因素、在用设备
+ +
+
登录密码
+
+ ●●●●●●●●●● + 上次修改 2026-04-12 + +
+
+
+
两步验证
// 推荐开启
+
+ + 短信 + Authenticator +
+
+ +

在用设备

+
// 不在此列表上的设备登录会触发短信告警
+
+
+
+
+
MacBook Pro · ChromeCURRENT
+
// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42
+
+
+ 当前会话 +
+
+
+
+
iPhone 15 · Safari
+
// 上海 · 2026-05-20 21:43
+
+
+ +
+
+
+
+
Windows · Edge
+
// 杭州 · 2026-05-18 09:12
+
+
+ +
+
+
+ +
+
+ + +
+

通知

+
// 邮件、短信、站内提示开关
+ +
+
项目完成通知
// 视频导出后
+
+ + 站内 · 邮件 · 短信 +
+
+
+
任务失败告警
+
+ + 站内 · 邮件 +
+
+
+
额度不足提醒
// 团队或个人剩余 < 20%
+
+ + 站内 · 短信 +
+
+
+
异地登录告警
+
+ + 短信 +
+
+
+ + +
+

创作默认

+
// 新建项目时的预填值,可在向导中改
+ +
+
默认模板
+
+
+
痛点种草
// 30s 默认档
+
开箱测评
// 45s 默认档
+
对比展示
// 45s 默认档
+
教程演示
// 60s 默认档
+
剧情带货
// 60s 默认档
+
+
+
+
+
默认时长档
+
+
+ 30s + 45s + 60s +
+ // 60s = 4 段 × 15s +
+
+
+
默认字幕样式
+
+
+
大字综艺
// 抖音热门
+
简洁电商
// 信息清晰
+
高级排版
// 居中衬线
+
弹幕轻量
// 滚动出现
+
强调爆款
// 高对比
+
+
+
+
+
默认 BGM 库
+
+ +
+
+
+
默认转场
+
+ +
+
+
+
导出水印
// VIP 可关闭
+
+ + 右下角 · Airshelf + 升级 VIP → +
+
+
+ + +
+

显示

+
// 界面外观与语言
+ +
+
外观
+
+ +
+
+
+
语言
+
+ +
+
+
+
表格密度
+
+ +
+
+
+ +
+ // Airshelf · v2.1 · build 20260521 +
+
+
+ + + + + + + +
+ + + + + + diff --git a/core/frontend/public/exact/team.html b/core/frontend/public/exact/team.html new file mode 100644 index 0000000..ab363e2 --- /dev/null +++ b/core/frontend/public/exact/team.html @@ -0,0 +1,1356 @@ + + + + +团队 · Airshelf + + + + + +
+ +
+
+

团队管理

+
// 成员 · 角色 · 额度 · 共享资产库
+
+
+ +
+
+ + +
+ + +
+ + + + + + + +
+ + +
+
+

团队动态

+ // 最近 12 条 + 全部 → +
+
+
+
+
+
张运营完成视频补水面膜 · v3
+
10 分钟前
+
+
+
+
+
+
王小姐上传到资产库林夕 · 主播图
+
28 分钟前
+
+
+
+
+
+
小李邀请新成员林新人
+
2 小时前
+
+
+
+
+
+
陈策划创建项目蓝牙耳机 · 开箱测评
+
4 小时前
+
+
+
+
+
+
张运营采用故事板补水面膜 · 场 3 · v2
+
昨天 18:32
+
+
+
+
+
+
小李团队充值+¥500.00
+
昨天 11:02
+
+
+
+
+
+
王小姐删除资产透真防晒 · 旧版主图
+
2 天前
+
+
+
+
+ +
+ +
+ +
+
+
+

成员列表 // 5 人 · 1 超管 / 1 团管 / 3 成员

+ + +
+ + + + + + + + + + + + + + +
成员角色每日额度月度额度当月已用操作
+
+
+ + +
+
+

角色权限

+
// PRD §10.2 权限矩阵节选
+ + + + + + + + + + + + + + +
能力超管团管成员
邀请 / 移除成员
设置成员额度
团队充值
设置月限额
编辑别人项目
团队共享资产库管理仅自传
查看团队消费明细仅自己
创建项目 / 用 AI 流程
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/core/frontend/public/exact/页面流程定稿.md b/core/frontend/public/exact/页面流程定稿.md new file mode 100644 index 0000000..fd8de71 --- /dev/null +++ b/core/frontend/public/exact/页面流程定稿.md @@ -0,0 +1,644 @@ +# Airshelf 页面流程定稿记录 + +> 本文件用于记录和用户逐页确认后的产品页面流程。 +> PRD 只作为功能边界参考,页面布局与交互以本文件后续定稿为准。 + +维护日期:2026-05-28 + +--- + +## 0. 总体原则 + +- 创作视频是产品主线任务。 +- 当前版本单条生成视频的时长上限是 15s。 +- AI 图片创作是附加能力,用于辅助商品图、人物、场景等素材生产,不抢视频主线。 +- 页面跳转尽量少,能在当前页面完成的操作尽量不跳转。 +- 操作尽量简单,第一次使用 AI 产品的用户也应该能理解。 +- 5 个 Stage 是步骤引导,不是强制锁定流程。 +- 用户可以在脚本、基础资产、故事板、视频、拼接导出之间自由跳转。 +- 如果某个 Stage 缺少前置内容,页面给提示和快捷补全入口,不做硬锁死。 +- 暂不做复制项目、归档、审核、待审核、待我确认等复杂项目管理概念。 + +--- + +## 1. 视频项目页 `/projects` + +### 页面定位 + +视频项目页是视频生产任务列表,帮助用户看清每个视频项目做到哪一步,以及下一步能点什么。 + +### 不做内容 + +- 不做复制项目。 +- 不做归档。 +- 不做审核 / 待审核 / 待我确认。 +- 不做草稿分类。 +- 不做复杂批量操作。 +- AI 图片创作不作为顶部主按钮。 + +### 状态分类 + +只保留: + +- 全部 +- 进行中 +- 生成中 +- 已完成 +- 失败 + +### 页面结构 + +1. Page Head + - 标题:视频项目 + - 主按钮:新建视频项目 + +2. 状态概览 / Tab + - 全部 + - 进行中 + - 生成中 + - 已完成 + - 失败 + +3. 工具栏 + - 搜索项目 / 商品名 + - 商品筛选 + - 状态筛选 + - 最近更新排序 + - 可选:列表 / 卡片视图切换 + +4. 项目列表 + - 默认列表行,不默认大卡片。 + - 每行展示: + - 9:16 缩略图 + - 项目名 + - 关联商品 + - 当前阶段 + - 5 段进度:脚本 / 资产 / 故事板 / 视频 / 导出 + - 更新时间 + - 状态 + - 主操作:继续 / 查看进度 / 查看成片 / 重试 + +5. 空状态 + - 没有商品:引导新建商品。 + - 有商品但没有项目:引导新建视频项目。 + +--- + +## 2. 新建视频项目页 `/projects/new` + +### 页面定位 + +新建项目页只负责创建项目壳子,并绑定商品。 +脚本怎么生成、脚本风格、任务设定等全部放到 Stage 1 脚本页。 + +### 交互原则 + +- 一个页面内完成,不做多步跳转。 +- 只问用户:为哪个商品创建视频。 +- 创建后直接进入项目流水线的 Stage 1 脚本页。 + +### 字段 + +必选: + +- 商品 + +可选: + +- 项目名称 + - 默认自动生成,例如:商品名 + 短视频 + 日期。 + +不展示: + +- 创作方式 +- 脚本风格 +- 任务设定 +- 商品卖点选择 +- 预估消耗 + +### 卖点选择归属 + +商品卖点不放在新建项目页。 +商品卖点选择放到 Stage 1 脚本页,作为“本条视频重点”配置项,从商品库自动带出。 + +--- + +## 3. Stage 1 脚本页 + +### 页面定位 + +脚本页是 AI 脚本工作台。 +用户通过 AI 输入和启动卡片完成脚本来源选择、卖点选择、风格设定、任务设定,并在中间区域查看完整读秒流分镜脚本。 + +### 核心原则 + +- 脚本是用户最关注的内容,必须放在中间最大区域。 +- 脚本不按 15s 大段展示。 +- 脚本按完整读秒流分镜展示,适合短视频细看。 +- AI 聊天不是主内容,而是脚本修改和生成记录。 +- AI 输入框放页面底部固定,作为当前 Stage 的全局指令入口。 + +### 页面布局 + +顶部: + +- 项目面包屑 +- 商品名 / 项目名 +- 5 Stage 自由切换导航:脚本 / 基础资产 / 故事板 / 视频 / 拼接导出 + +主体: + +- 左侧窄栏:商品信息 / 卖点 / 人物 / 场景等辅助信息 +- 中间主栏:读秒流分镜脚本 +- 右侧窄栏:AI 对话记录 / 操作历史 + +底部固定栏: + +- AI 输入框 +- 页面操作按钮,例如重新生成全部、进入下一步 + +### 空状态引导 + +脚本来源不做 Tab。 +脚本未生成时,中间主区域展示启动卡片: + +- AI 帮我写 + 根据商品信息自动生成完整读秒流分镜。 + +- 我有脚本 + 粘贴已有口播稿,AI 帮你整理成分镜结构。 + +- 一句话生成 + 输入一句视频方向,AI 生成完整分镜。 + +- 复刻爆款 + 暂未开放或后续版本处理。 + +用户选择后进入对应模式。生成脚本后,中间区域切换为完整读秒流分镜。 + +### 读秒流分镜结构 + +每个分镜以卡片 / 行卡展示: + +- 镜头编号 +- 时间段,例如 0-5s、5-10s +- 画面描述 +- 对白 / 旁白 +- 可选:镜头类型、场景、人物 + +用户可以: + +- 直接编辑某个分镜文字。 +- 用底部 AI 输入框修改某个分镜。 +- 重新生成全部。 +- 调整整体时长、口吻、卖点重点等。 + +### 商品卖点 + +- 从商品库自动带出。 +- 在脚本页展示为“本条视频重点”。 +- 默认选中前 2-3 个,或默认使用全部卖点。 +- 用户可点选 / 取消。 +- 用户也可以直接在 AI 输入框里说重点突出哪些卖点。 + +--- + +## 4. Stage 2 基础资产页 + +### 页面定位 + +基础资产页用于为当前视频准备视觉资产。 +它不是资产库,也不是完整 AI 图片创作页。 + +### 总体原则 + +- 默认 AI 自动生成,用户只在效果不好时调整。 +- 脚本确定后,AI 自动识别需要的商品、人物、场景,并生成图片提示词。 +- 页面优先让用户看默认生成效果。 +- 不满意时再修改提示词、上传参考图、从资产库选择或重新生成。 +- 商品是最重要的,优先展示商品资产。 + +### 页面布局 + +- 左侧:资产清单 +- 中间:当前资产工作台 / 生成结果 +- 右侧:AI 操作记录 / 生成记录 +- 底部:AI 输入框 + +### 资产顺序 + +1. 商品资产 +2. 人物 +3. 场景 + +### 商品资产 + +商品三视图是一张 16:9 图,包含正面、侧面、背面。 +它不是三张图,也不拆分成三个槽位。 + +不提供: + +- 上传三视图 +- 使用现有商品图 + +原因: + +- 用户一般不会有适合生成视频的白底三视图。 +- 如果不生成三视图,系统本来就只能默认使用商品图继续,不需要多给一个“使用现有商品图”的操作。 + +推荐交互: + +- 展示当前商品图。 +- 展示商品三视图状态:未生成 / 生成中 / 已生成。 +- 说明:生成 16:9 商品三视图可以提升商品角度稳定性。 +- 主操作:生成商品三视图。 +- 不生成三视图也可以继续,只做弱提示。 + +生成规则: + +- 商品三视图属于结构转换型。 +- 默认一次生成 1 张。 +- 不满意可重新生成或用 AI 输入框调整提示词。 +- 商品三视图支持保留历史版本,用户可以在历史版本中选择满意的一版采用。 + +### 人物资产 + +命名为“人物”,不叫“模特”。 +人物指脚本中需要出现的角色,用户要确定这个角色的形象。 + +默认流程: + +1. AI 根据脚本自动识别人物。 +2. AI 为每个人物自动生成图片提示词。 +3. 默认生成 4 张人物立绘候选。 +4. 用户选择 1 张满意的立绘。 +5. AI 基于选中的立绘生成 1 张 16:9 人物三视图。 +6. 用户采用此人物资产。 + +不满意时,用户可以: + +- 修改描述。 +- 上传自己的模特 / 参考图。 +- 从人物库选择。 +- 重新生成立绘。 +- 从历史立绘里再选一张生成三视图。 +- 重新生成人物三视图。 +- 从人物三视图历史版本中选择满意的一版采用。 + +人物调整不放抽屉。 +点击人物后,中间区域直接切换为人物工作台。 + +人物工作台需要一步到位承载: + +- 人物库选择 +- 上传参考图 +- 描述生成 +- 直接使用自己的模特 +- AI 生成多张立绘 +- 从立绘生成三视图 +- 从历史立绘再选再生成三视图 + +### 场景资产 + +默认流程: + +1. AI 根据脚本识别场景。 +2. AI 自动生成场景图提示词。 +3. 默认一次生成 4 张候选。 +4. 用户选择 1 张。 + +不满意时,用户可以: + +- 修改描述。 +- 重新生成。 +- 上传替换。 + +### 候选生成规则 + +创意选择型:一次生成 4 张。 + +- 人物立绘 +- 场景图 + +结构转换型:一次生成 1 张。 + +- 商品三视图 +- 人物三视图 + +三视图版本规则: + +- 商品三视图和人物三视图都可以重跑。 +- 每次重跑产生一个历史版本。 +- 用户可以在历史版本中回选满意的一版。 +- 采用某一版后,该版本成为当前项目使用版本。 + +### 资产复用 + +人物默认是项目内资产,不自动进入团队人物库,避免资产库变乱。 + +采用人物时可以勾选: + +- 保存到人物库 + +保存后: + +- 进入团队共享人物库。 +- 后续项目可复用。 +- 记录来源:项目内生成 / AI 生成 / 上传参考图等。 + +--- + +## 5. Stage 3 故事板页(已确认原则,待细化) + +### 页面定位 + +故事板页是可选的预览步骤,不是视频生成的必经步骤。 + +### 已确认原则 + +- 故事板不是一定要生成。 +- 用户可以跳过故事板,直接进入视频生成。 +- 故事板的价值是提前预览视频大致画面和内容。 +- 故事板有时会固定最终视频画面,反而可能让视频生成效果不如不使用故事板。 +- 因此故事板不能作为强制前置条件。 +- Stage 3 应该提供“生成故事板预览”和“跳过,直接生成视频”两条路径。 +- 如果用户跳过故事板,Stage 4 直接基于脚本和基础资产生成视频。 +- 故事板提示词直接在原提示词基础上修改。 +- 暂不做独立 AI 辅助输入框或 AI 聊天式提示词修改。 +- 故事板绑定资产需要展示,但只做轻量引用说明。 +- 绑定资产默认不展示缩略图。 +- 绑定资产用文字标签 / 资产引用标签展示。 +- Hover / Popover 时展示资产详情。 +- Stage 3 不直接编辑资产,修改资产需要回到 Stage 2。 +- 如果绑定资产在 Stage 2 被替换,Stage 3 显示“绑定资产已更新,建议重新生成故事板”。 + +### 绑定资产展示 + +绑定资产展示的是引用关系,而不是图片预览。 + +示例: + +- 商品:补水面膜 +- 人物:女主、同事 +- 场景:卫生间、书桌 + +或以标签形式展示: + +- 商品 · 补水面膜 +- 人物 · 女主 +- 人物 · 同事 +- 场景 · 卫生间 + +Hover 详情示例: + +- 商品 · 补水面膜 + - 商品图数量 + - 商品三视图状态 + - 来源:商品库 + - 操作:查看 / 去基础资产修改 + +- 人物 · 女主 + - 当前立绘状态 + - 人物三视图状态 + - 来源:项目内 / 人物库 + - 操作:查看 / 去基础资产修改 + +### 提示词编辑 + +- 用户直接在原本提示词基础上修改。 +- 提示词就是重新生成故事板的依据。 +- 第一版不做 AI 辅助修改提示词。 +- 可保留恢复原始提示词、重新生成等基础操作。 + +### 待继续确认 + +- 故事板是否进入页面后自动生成,还是由用户点击生成。 +- 生成视频时,已生成故事板是否默认作为参考,还是由用户选择是否使用。 +- 故事板历史版本如何展示。 + +--- + +## 6. 待继续确认页面 + +- Stage 5 拼接导出页 +- 商品库 / 商品详情 +- 资产库 +- AI 图片创作 +- 人物库 +- 工作台 +- 团队 +- 消费 +- 设置 + +--- + +## 7. Stage 5 拼接导出页 + +### 页面定位 + +拼接导出页是最终成片工作台。 +它负责把 Stage 4 采用的 AI 视频片段、用户上传的视频、资产库视频等素材自由拼接,并完成字幕、BGM、转场和导出。 + +### 布局方向 + +参考剪映 / CapCut 的基础剪辑布局,但只保留轻量功能。 + +页面区域: + +- 顶部:项目名、Stage 导航、导出状态 +- 左侧:素材池 +- 中间上方:成片预览播放器 +- 右侧:属性设置 +- 底部:时间线 + +### 素材池 + +素材池包含: + +- AI 生成视频片段 +- 上传视频 +- 批量上传视频 +- 从资产库选择视频 + +上传视频时支持勾选: + +- 保存到资产库,供以后复用 + +默认不勾选,避免资产库变乱。 + +### 时间线 + +第一版只做单主轨轻剪辑。 + +支持: + +- 自动放入 Stage 4 当前采用的 AI 视频片段,按顺序排列 +- 拖拽排序 +- 添加上传视频 +- 删除片段 +- 替换片段 +- 裁剪起止点 + +不做: + +- 多轨自由叠加 +- 关键帧 +- 复杂音频混音 +- 速度曲线 +- 画中画 +- 蒙版 +- 贴纸 / 复杂特效 + +### 裁剪 + +需要支持裁剪。 + +选中时间线片段后,右侧属性区显示: + +- 开始时间 +- 结束时间 +- 当前片段时长 +- 应用裁剪 + +时间线片段两侧可保留轻量拖拽手柄。 + +### 字幕 + +字幕默认自动添加。 + +规则: + +- 进入 Stage 5 后,系统根据脚本台词 / 旁白自动生成字幕。 +- 字幕默认开启。 +- 用户可以关闭字幕。 +- 用户可以编辑字幕文字和时间。 +- 第一版不强制支持识别用户上传视频语音生成字幕。 + +右侧属性区默认展示: + +- 字幕开关 +- 字幕来源:脚本台词 +- 字幕样式 +- 编辑字幕入口 + +字幕编辑使用轻量面板,不做复杂字幕工作台。 + +示例: + +- 00:00 - 00:03 熬夜后皮肤干到起皮? +- 00:03 - 00:06 这片补水面膜我最近一直在用。 +- 00:06 - 00:10 敷完脸会明显更透亮。 + +### BGM / 转场 / 导出 + +右侧属性区包含: + +- BGM 开关 +- BGM 选择 +- 原声 / BGM 音量 +- 转场开关 +- 转场类型 +- 导出比例,默认 9:16 +- 清晰度,默认 1080P +- 格式,默认 MP4 + +BGM 默认开启,用户可修改或关闭。 +转场默认开启,用户可修改或关闭。 + +### 导出成功 + +导出成功后不新增独立页面。 +在 Stage 5 当前页面展示导出成功状态 / 结果面板。 + +导出中: + +- 显示导出进度。 + +导出成功后展示: + +- 成片预览播放器 +- 下载 MP4 +- 复制链接 +- 查看成片资产 +- 继续编辑 +- 返回视频项目 + +导出成功后: + +- 项目状态标记为已完成。 +- 成片自动进入项目资产。 + +后续如果接入平台发布能力,再增加发布入口;第一版不做独立发布页。 + +--- + +## 6. Stage 4 视频页 + +### 页面定位 + +视频页只用于 AI 生成视频片段。 +用户上传视频、使用自己的视频、与 AI 视频混合拼接,都放到 Stage 5 拼接导出页处理。 + +### 核心原则 + +- 视频生成以片段为单位。 +- 每段视频独立生成。 +- 单个视频生成片段上限是 15s。 +- 长视频由多个 15s 内的视频片段组成,后续在 Stage 5 拼接。 +- 视频一次只生成 1 条候选。 +- 首次进入视频页后,AI 可以自动生成视频,不需要用户手动点击生成。 +- 视频有自己的生成提示词。 +- 用户可以直接修改视频提示词。 +- 用户可以不改提示词直接重跑。 +- 每次重跑保留历史版本。 +- 用户可以从历史版本中回选满意的一版。 + +### 视频来源 + +Stage 4 只展示和管理 AI 生成视频片段。 + +每个 AI 片段的视频来源是: + +- 当前采用的 AI 生成版本 +- AI 历史版本中的某一版 + +### 故事板使用 + +如果当前片段有故事板,默认使用故事板生成视频。 + +原因: + +- 对小白用户来说,“使用 / 不使用故事板”解释成本高。 +- 用户既然生成了故事板,视频结果和故事板不一致时容易产生困惑。 + +Stage 4 可以允许用户修改是否使用故事板,但默认不主动要求用户选择。 + +### 页面结构初稿 + +左侧: + +- 片段列表 +- 每段显示时间范围、来源、生成状态、当前采用版本 + +中间: + +- 当前片段视频预览 / 生成状态 +- 视频播放器 +- 当前采用版本标识 + +右侧: + +- 视频提示词编辑器 +- 故事板使用状态 / 可修改 +- 绑定输入资产引用 +- 视频历史版本 + +底部: + +- 重新生成 +- 采用当前版本 +- 进入拼接导出 + +### 待继续确认 + +- 视频历史版本如何展示。 diff --git a/core/frontend/scripts/generate-exact-html.mjs b/core/frontend/scripts/generate-exact-html.mjs new file mode 100644 index 0000000..4ada5ce --- /dev/null +++ b/core/frontend/scripts/generate-exact-html.mjs @@ -0,0 +1,94 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(fileURLToPath(import.meta.url)); +const frontendRoot = join(root, ".."); +const exactRoot = join(frontendRoot, "public", "exact"); +const outFile = join(frontendRoot, "src", "routes", "exact-html.ts"); +const componentRoot = join(frontendRoot, "src", "routes", "exact-pages"); + +const pages = [ + ["account", "account.html"], + ["assetFactory", "asset-factory.html"], + ["dashboard", "index.html"], + ["imageOptimize", "image-optimize.html"], + ["library", "library.html"], + ["login", "login.html"], + ["messages", "messages.html"], + ["modelPhoto", "model-photo.html"], + ["modelPhotoDemoA", "model-photo-demo-a.html"], + ["modelPhotoDemoB", "model-photo-demo-b.html"], + ["pipeline", "pipeline.html"], + ["platformCover", "platform-cover.html"], + ["productCreate", "product-create.html"], + ["productCreateUpload", "product-create-upload.html"], + ["productDetail", "product-detail.html"], + ["products", "products.html"], + ["projectWizard", "projects-new.html"], + ["projects", "projects.html"], + ["register", "register.html"], + ["settings", "settings.html"], + ["team", "team.html"] +]; + +function normalizeDocument(html, fileName) { + const withMeta = html.replace( + //i, + `\n` + ); + return withMeta + .replace(/(src|href)="assets\//g, '$1="/exact/assets/') + .replace(/url\((['"]?)assets\//g, "url($1/exact/assets/") + .replace( + "history.replaceState(null, '', '#' + id);", + "try { window.location.hash = id; } catch (e) {}" + ); +} + +mkdirSync(dirname(outFile), { recursive: true }); +mkdirSync(componentRoot, { recursive: true }); + +const entries = pages + .map(([key, fileName]) => { + const html = normalizeDocument(readFileSync(join(exactRoot, fileName), "utf8"), fileName); + return ` ${JSON.stringify(key)}: ${JSON.stringify(html)}`; + }) + .join(",\n"); + +const keys = pages.map(([key]) => JSON.stringify(key)).join(" | "); + +writeFileSync( + outFile, + `/* This file is generated by scripts/generate-exact-html.mjs. Do not edit by hand. */\n` + + `export type ExactHtmlKey = ${keys};\n\n` + + `export const exactHtmlDocuments: Record = {\n${entries}\n};\n`, + "utf8" +); + +function toPascalCase(value) { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .split(/[-_]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +for (const [key] of pages) { + const componentName = `Exact${toPascalCase(key)}Page`; + const fileName = `${key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()}.tsx`; + writeFileSync( + join(componentRoot, fileName), + `/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */\n` + + `import { ExactDocumentPage } from "../exact-document";\n` + + `import type { ExactDocumentPageProps } from "../exact-document";\n\n` + + `export function ${componentName}(props: Omit) {\n` + + ` return ;\n` + + `}\n\n` + + `export default ${componentName};\n`, + "utf8" + ); +} + +console.log(`generated ${pages.length} exact documents -> ${outFile}`); diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx new file mode 100644 index 0000000..b69b1ff --- /dev/null +++ b/core/frontend/src/App.tsx @@ -0,0 +1,422 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Bell, CircleDollarSign } from "lucide-react"; +import { api, getToken, setToken } from "./api"; +import type { + AITask, + Asset, + BillingSummary, + Ledger, + ModelConfig, + Product, + Project, + Team, + TeamMember, + User +} from "./types"; +import { Decorations, Sidebar, ToastLike } from "./components/app-shell"; +import { + AccountPage, + AssetFactoryPage, + AuthScreen, + Dashboard, + ImageWorkbenchPage, + LibraryPage, + MessagesPage, + ModelPhotoDemoPage, + PipelinePage, + ProductCreateUploadPage, + ProductDetailPage, + ProductsPage, + ProjectWizardPage, + ProjectsPage, + SettingsPage, + TeamPage +} from "./routes"; +import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config"; +import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config"; +import { money } from "./routes/stage-config"; + +export function App() { + const [route, setRoute] = useState(() => resolveRoute()); + const page = route.page; + const [authMode, setAuthMode] = useState(route.authMode); + const [authed, setAuthed] = useState(() => Boolean(getToken())); + const [booting, setBooting] = useState(() => Boolean(getToken())); + + const [user, setUser] = useState(null); + const [team, setTeam] = useState(null); + const [products, setProducts] = useState([]); + const [projects, setProjects] = useState([]); + const [assets, setAssets] = useState([]); + const [teamMembers, setTeamMembers] = useState([]); + const [modelConfigs, setModelConfigs] = useState([]); + const [aiTasks, setAiTasks] = useState([]); + const [billing, setBilling] = useState(null); + const [ledgers, setLedgers] = useState([]); + const [projectDetail, setProjectDetail] = useState(null); + + const [activeProductId, setActiveProductId] = useState(route.productId || ""); + const [activeProjectId, setActiveProjectId] = useState(route.projectId || ""); + const [notice, setNotice] = useState(null); + const [loading, setLoading] = useState(false); + + const activeProject = useMemo( + () => projects.find((project) => project.id === activeProjectId) || projects[0], + [projects, activeProjectId] + ); + const activeProduct = useMemo( + () => products.find((product) => product.id === activeProductId) || products[0], + [products, activeProductId] + ); + + const loadData = useCallback(async () => { + const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData] = + await Promise.all([ + api.products(), + api.projects(), + api.assets(), + api.billingSummary().catch(() => null), + api.ledgers().catch(() => []), + api.teamMembers().catch(() => []), + api.modelConfigs().catch(() => null), + api.aiTasks().catch(() => null) + ]); + setProducts(productData.results); + setProjects(projectData.results); + setAssets(assetData.results); + setTeamMembers(memberData); + setModelConfigs(modelData?.results || []); + setAiTasks(taskData?.results || []); + if (billingData) setBilling(billingData); + setLedgers(ledgerData); + setActiveProjectId((current) => current || projectData.results[0]?.id || ""); + setActiveProductId((current) => current || productData.results[0]?.id || ""); + }, []); + + // Boot: validate token, hydrate identity + data. + useEffect(() => { + if (!getToken()) { + setBooting(false); + return; + } + let cancelled = false; + (async () => { + try { + const identity = await api.me(); + if (cancelled) return; + setUser(identity.user); + setTeam(identity.team); + await loadData(); + } catch { + setToken(null); + if (!cancelled) setAuthed(false); + } finally { + if (!cancelled) setBooting(false); + } + })(); + return () => { + cancelled = true; + }; + }, [loadData]); + + // Keep route in sync with browser navigation. + useEffect(() => { + function syncRouteFromHistory() { + const next = resolveRoute(); + setRoute(next); + setAuthMode(next.authMode); + if (next.productId !== undefined) setActiveProductId(next.productId); + if (next.projectId !== undefined) setActiveProjectId(next.projectId); + } + window.addEventListener("popstate", syncRouteFromHistory); + return () => window.removeEventListener("popstate", syncRouteFromHistory); + }, []); + + // Load full project detail when entering the pipeline. + useEffect(() => { + if (!authed || page !== "pipeline" || !activeProjectId) { + if (page !== "pipeline") setProjectDetail(null); + return; + } + let cancelled = false; + api + .project(activeProjectId) + .then((detail) => { + if (!cancelled) setProjectDetail(detail); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [authed, page, activeProjectId]); + + function navigate(next: Page, options: NavigateOptions = {}) { + const productId = options.productId ?? activeProductId; + const projectId = options.projectId ?? activeProjectId; + if (options.productId !== undefined) setActiveProductId(options.productId); + if (options.projectId !== undefined) setActiveProjectId(options.projectId); + const hash = options.hash?.replace(/^#/, ""); + setRoute({ page: next, authMode, productId, projectId, hash }); + const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`; + if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) { + const method = options.replace ? "replaceState" : "pushState"; + window.history[method](null, "", path); + } + window.scrollTo({ top: 0, behavior: "auto" }); + } + + async function refreshProjectDetail() { + if (!activeProjectId) return; + const detail = await api.project(activeProjectId).catch(() => null); + if (detail) setProjectDetail(detail); + } + + async function action(work: () => Promise, successText: string): Promise { + setLoading(true); + setNotice(null); + try { + const result = await work(); + setNotice({ type: "success", text: successText }); + await loadData(); + await refreshProjectDetail(); + return result; + } catch (error) { + setNotice({ type: "error", text: error instanceof Error ? error.message : "操作失败" }); + return null; + } finally { + setLoading(false); + } + } + + function onAuthed(payload: { token: string; user: User; team: Team }) { + setToken(payload.token); + setUser(payload.user); + setTeam(payload.team); + setAuthed(true); + setBooting(true); + loadData().finally(() => setBooting(false)); + navigate("dashboard", { replace: true }); + } + + async function logout() { + await api.logout().catch(() => undefined); + setToken(null); + setAuthed(false); + setUser(null); + setTeam(null); + setAuthMode("login"); + window.history.replaceState(null, "", "/login"); + } + + // ---- Auth gate ---- + if (!authed) { + return ( + { + setAuthMode(next); + window.history.pushState(null, "", next === "register" ? "/register" : "/login"); + }} + onAuthed={onAuthed} + /> + ); + } + + if (booting || !user || !team) { + return ( +
+
+
+
+
+

加载中…

+
+ // 正在拉取团队数据 +
+
+
+
+
+
+ ); + } + + const currentUser: User = user; + const currentTeam: Team = team; + + function renderPage() { + switch (page) { + case "dashboard": + return ; + case "products": + return ( + navigate("productDetail", { productId })} + onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} + /> + ); + case "productCreateUpload": + return ( + { + const created = await action(() => api.createProduct(payload), "商品已创建"); + if (created) navigate("productDetail", { productId: created.id }); + }} + onBack={() => navigate("products")} + /> + ); + case "productDetail": + if (!activeProduct) return navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />; + return ( + project.product === activeProduct.id)} + navigate={navigate} + onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")} + onDelete={async () => { + const ok = await action(() => api.deleteProduct(activeProduct.id), "商品已删除"); + if (ok !== null) navigate("products"); + }} + /> + ); + case "projects": + return ( + action(() => api.createProject(payload), "项目已创建")} + openPipeline={(projectId) => navigate("pipeline", { projectId })} + onDelete={(projectId) => action(() => api.deleteProject(projectId), "项目已删除")} + /> + ); + case "projectWizard": + return ( + navigate("projects")} + onCreate={async (payload) => { + const created = await action(() => api.createProject(payload), "项目已创建"); + if (created) navigate("pipeline", { projectId: created.id }); + }} + /> + ); + case "pipeline": { + const project = projectDetail || activeProject; + if (!project) { + return ( +
+
+

暂无项目

+
+ // 先创建一个视频项目 +
+
+
+ +
+
+ ); + } + return ( + action(() => api.generateScript(project.id, { prompt }), "脚本已生成")} + onAdoptScript={(scriptId) => action(() => api.adoptScript(project.id, scriptId), "脚本已采用")} + onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(project.id, { kind, prompt }), "基础资产已生成")} + onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(project.id, { prompt }), "故事板已生成")} + onSkipStoryboard={() => action(() => api.skipStoryboard(project.id), "已跳过故事板")} + onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(project.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")} + onPollVideo={(segmentId) => action(() => api.pollVideo(project.id, segmentId), "片段状态已刷新")} + onSubmitAllVideos={(prompt) => + action(async () => { + const targets = project.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status)); + for (const segment of targets) { + await api.submitVideo(project.id, { + video_segment_id: segment.id, + prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒` + }); + } + return targets.length; + }, "60s 多段视频任务已提交") + } + onPollAllVideos={() => + action(async () => { + const targets = project.video_segments.filter((segment) => ["running", "queued"].includes(segment.status)); + for (const segment of targets) { + await api.pollVideo(project.id, segment.id).catch(() => undefined); + } + return targets.length; + }, "视频片段状态已刷新") + } + onSubmitExport={() => action(() => api.submitExport(project.id), "导出任务已提交")} + /> + ); + } + case "library": + return action(() => api.uploadAsset(formData), "资产已上传")} />; + case "account": + return ; + case "team": + return ; + case "messages": + return ; + case "assetFactory": + return ; + case "imageOptimize": + return navigate("assetFactory")} navigate={navigate} />; + case "modelPhoto": + return navigate("assetFactory")} navigate={navigate} />; + case "platformCover": + return navigate("assetFactory")} navigate={navigate} />; + case "modelPhotoDemoA": + return navigate("modelPhoto")} />; + case "modelPhotoDemoB": + return navigate("modelPhoto")} />; + case "settings": + return ; + case "settingsNotify": + return ; + default: + return ; + } + } + + const avatarChar = (user.username || "U").slice(0, 1).toUpperCase(); + + return ( +
+ +
+ +
+
+ {routeLabels[page]} +
+
+ + + +
+
+
+ {notice && } + {renderPage()} +
+
+
+ ); +} diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts new file mode 100644 index 0000000..7952368 --- /dev/null +++ b/core/frontend/src/api.ts @@ -0,0 +1,161 @@ +import type { + AITask, + Asset, + AuthPayload, + BillingSummary, + Ledger, + ModelConfig, + Paginated, + Product, + Project, + ScriptVersion, + Team, + TeamMember, + User +} from "./types"; + +const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; +const TOKEN_KEY = "airshelf_token"; + +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string | null) { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +async function request(path: string, options: RequestInit = {}): Promise { + const token = getToken(); + const headers = new Headers(options.headers); + if (!(options.body instanceof FormData)) { + headers.set("Content-Type", "application/json"); + } + if (token) headers.set("Authorization", `Token ${token}`); + + const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); + if (!response.ok) { + const text = await response.text(); + throw new ApiError(response.status, text || `${response.status} ${response.statusText}`); + } + if (response.status === 204) return undefined as T; + return response.json() as Promise; +} + +export const api = { + register(payload: { username: string; password: string; email?: string; team_name?: string }) { + return request("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) }); + }, + login(payload: { username: string; password: string }) { + return request("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) }); + }, + me() { + return request<{ user: User; team: Team }>("/api/auth/me/"); + }, + logout() { + return request("/api/auth/logout/", { method: "POST" }); + }, + teamMembers() { + return request("/api/auth/team/members/"); + }, + products() { + return request>("/api/products/"); + }, + product(id: string) { + return request(`/api/products/${id}/`); + }, + createProduct(payload: { + title?: string; + brand?: string; + category?: string; + target_audience?: string; + description?: string; + specs?: Record; + selling_points?: Array<{ title: string; detail: string; sort_order: number }>; + }) { + return request("/api/products/", { method: "POST", body: JSON.stringify(payload) }); + }, + updateProduct(id: string, payload: Partial) { + return request(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, + deleteProduct(id: string) { + return request(`/api/products/${id}/`, { method: "DELETE" }); + }, + projects() { + return request>("/api/projects/"); + }, + project(id: string) { + return request(`/api/projects/${id}/`); + }, + createProject(payload: { name: string; product: string }) { + return request("/api/projects/", { method: "POST", body: JSON.stringify(payload) }); + }, + deleteProject(id: string) { + return request(`/api/projects/${id}/`, { method: "DELETE" }); + }, + generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) { + return request(`/api/projects/${projectId}/generate-script/`, { + method: "POST", + body: JSON.stringify(payload) + }); + }, + adoptScript(projectId: string, script_version_id: string) { + return request(`/api/projects/${projectId}/adopt-script/`, { + method: "POST", + body: JSON.stringify({ script_version_id }) + }); + }, + generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) { + return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); + }, + adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { + return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); + }, + generateStoryboard(projectId: string, payload: { prompt: string }) { + return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) }); + }, + skipStoryboard(projectId: string) { + return request(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" }); + }, + submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) { + return request(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) }); + }, + pollVideo(projectId: string, video_segment_id: string) { + return request(`/api/projects/${projectId}/poll-video-segment/`, { + method: "POST", + body: JSON.stringify({ video_segment_id }) + }); + }, + submitExport(projectId: string) { + return request(`/api/projects/${projectId}/submit-export/`, { method: "POST" }); + }, + assets() { + return request>("/api/assets/"); + }, + uploadAsset(formData: FormData) { + return request("/api/assets/upload/", { method: "POST", body: formData }); + }, + billingSummary() { + return request("/api/billing/summary/"); + }, + ledgers() { + return request("/api/billing/ledgers/"); + }, + modelConfigs() { + return request>("/api/ai/models/"); + }, + aiTasks() { + return request>("/api/ai/tasks/"); + } +}; diff --git a/core/frontend/src/components/IconKitSvg.tsx b/core/frontend/src/components/IconKitSvg.tsx new file mode 100644 index 0000000..45b47ab --- /dev/null +++ b/core/frontend/src/components/IconKitSvg.tsx @@ -0,0 +1,46 @@ + + +const iconPaths: Record = { + layoutDashboard: '', + package: '', + clapperboard: '', + sparkles: '', + images: '', + folder: '', + users: '', + creditCard: '', + settings: '', + search: '', + bell: '', + chevronLeft: '', + chevronRight: '', + productPlus: '', + arrowUp: '', + helpCircle: '' +}; + +const iconAliases: Record = { + dashboard: "layoutDashboard", + library: "folder" +}; + +export function IconKitSvg({ name, size = 16, strokeWidth = 1.5 }: { name: string; size?: number; strokeWidth?: number }) { + const key = iconAliases[name] || name; + return ( + + ); +} diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx new file mode 100644 index 0000000..b11af08 --- /dev/null +++ b/core/frontend/src/components/app-shell.tsx @@ -0,0 +1,80 @@ +import { Check, Search } from "lucide-react"; +import type { Product, User } from "../types"; +import type { NavItem, Notice, Page } from "../routes/route-config"; +import { mainNav, parentPage, supplementNav } from "../routes/route-config"; + +type Navigate = (page: Page) => void; + +export function Sidebar({ page, navigate, user, products }: { page: Page; navigate: Navigate; user: User; products: Product[] }) { + const activePage = parentPage(page); + return ( + + ); +} + +export function NavButton({ item, active, navigate }: { item: NavItem; active: boolean; navigate: Navigate }) { + const Icon = item.icon; + return ( + + ); +} + +export function Decorations() { + // Exact 设计稿只用 grid-bg 做底纹;旧版散点/角标是绝对定位写死坐标,会压在 + // topbar 和 page-head 上(重叠 bug),且不在设计稿里。品牌 mono 签名由各页面 inline + // 的 // 注释、[ ALL ] 等承载,这里只保留底纹。 + return
; +} + +export function ToastLike({ notice }: { notice: NonNullable }) { + return ( +
+
+
{notice.text}[ {notice.type.toUpperCase()} ]
+
+ ); +} + +export function CornerMark({ pos }: { pos: "tl" | "tr" | "bl" | "br" }) { + return ( + + + + + + ); +} diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx new file mode 100644 index 0000000..9f4ed85 --- /dev/null +++ b/core/frontend/src/components/overlays.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { Shield, X } from "lucide-react"; + +export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) { + return ( +
+
{title}{desc}
+ {toggle ? : } +
+ ); +} + +export function TeamModal({ open, title, subtitle, icon, close, children, footer }: { + open: boolean; + title: string; + subtitle: string; + icon: ReactNode; + close: () => void; + children: ReactNode; + footer?: ReactNode; +}) { + if (!open) return null; + return ( +
+
event.stopPropagation()}> + ++ +
{icon}
{title}{subtitle}
+
{children}
+
{footer || }
+
+
+ ); +} + +export function ConfirmModal({ open, title, detail, confirmText, onCancel, onConfirm }: { + open: boolean; + title: string; + detail: string; + confirmText: string; + onCancel: () => void; + onConfirm: () => void | Promise; +}) { + if (!open) return null; + return ( +
+
event.stopPropagation()}> + ++ +
{title}// CONFIRM
+
{detail}
+
+
+
+ ); +} + +export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) { + if (!open) return null; + return ( + <> +
+ + + ); +} + +export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) { + return

{title}

// 先创建资料后进入下一步

; +} diff --git a/core/frontend/src/components/pipeline-stage.tsx b/core/frontend/src/components/pipeline-stage.tsx new file mode 100644 index 0000000..20f055a --- /dev/null +++ b/core/frontend/src/components/pipeline-stage.tsx @@ -0,0 +1,29 @@ +import { stageMeta, stageOrder } from "../routes/stage-config"; + +export function Progress({ status }: { status: string }) { + const current = Math.max(stageOrder.indexOf(status as never), 0); + return ( +
+ {stageOrder.map((stage, index) => )} +
+ ); +} + +export function FragmentStageStep({ stage, done, active, lineDone, isLast, onClick }: { + stage: string; + done: boolean; + active: boolean; + lineDone: boolean; + isLast: boolean; + onClick: () => void; +}) { + const meta = stageMeta[stage]; + return ( + <> + + {!isLast &&
} + + ); +} diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css new file mode 100644 index 0000000..fccb2e5 --- /dev/null +++ b/core/frontend/src/design-restraint.css @@ -0,0 +1,2270 @@ +/* ============================================================ + Airshelf · Restraint V2.1 · Firecrawl-aligned · 纯净版 + ============================================================ + 严格遵循 DESIGN_SPEC_V2.md V2.1 · 不含任何 V1/V2 legacy alias + ============================================================ */ + +/* ============ Web font · Alibaba PuHuiTi 3.0 ============ */ +@font-face { + font-family: 'Alibaba PuHuiTi'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: local('Alibaba PuHuiTi 3.0'), + local('AlibabaPuHuiTi-3-55-Regular'), + local('Alibaba PuHuiTi 2.0'), + local('AlibabaPuHuiTi-2-55-Regular'); +} +@font-face { + font-family: 'Alibaba PuHuiTi'; + font-weight: 500; + font-style: normal; + font-display: swap; + src: local('Alibaba PuHuiTi 3.0 Medium'), + local('AlibabaPuHuiTi-3-65-Medium'), + local('AlibabaPuHuiTi-2-65-Medium'); +} +@font-face { + font-family: 'Alibaba PuHuiTi'; + font-weight: 600; + font-style: normal; + font-display: swap; + src: local('AlibabaPuHuiTi-3-75-SemiBold'), + local('AlibabaPuHuiTi-2-75-SemiBold'); +} +@font-face { + font-family: 'Alibaba PuHuiTi'; + font-weight: 700; + font-style: normal; + font-display: swap; + src: local('Alibaba PuHuiTi 3.0 Bold'), + local('AlibabaPuHuiTi-3-85-Bold'), + local('AlibabaPuHuiTi-2-85-Bold'); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + /* ===== Backgrounds (冷灰) ===== */ + --background-base: #f9f9f9; + --background-lighter: #fbfbfb; + --surface: #ffffff; + --surface-raised: #ffffff; + + /* ===== Borders (冷灰 3 档,语义优先) ===== */ + --border-faint: #ededed; + --border-muted: #e8e8e8; + --border-loud: #e6e6e6; + + /* ===== Accent multi-color (5 色信号) ===== */ + --accent-black: #262626; + --accent-white: #ffffff; + --accent-amethyst: #9061ff; + --accent-bluetron: #2a6dfb; + --accent-crimson: #eb3424; + --accent-forest: #42c366; + --accent-honey: #ecb730; + + /* status 配套底/边 */ + --forest-bg: rgba(66, 195, 102, .08); + --forest-bd: rgba(66, 195, 102, .20); + --crimson-bg: rgba(235, 52, 36, .08); + --crimson-bd: rgba(235, 52, 36, .20); + --honey-bg: rgba(236, 183, 48, .08); + --honey-bd: rgba(236, 183, 48, .20); + + /* ===== Heat · 单 hue + 8 档 alpha ===== */ + --heat: #fa5d19; + --heat-90: rgba(250, 93, 25, .90); + --heat-40: rgba(250, 93, 25, .40); + --heat-20: rgba(250, 93, 25, .20); + --heat-16: rgba(250, 93, 25, .16); + --heat-12: rgba(250, 93, 25, .12); + --heat-8: rgba(250, 93, 25, .08); + --heat-4: rgba(250, 93, 25, .04); + + /* ===== Black-alpha 阶梯 (20 档) ===== */ + --black-alpha-1: rgba(0, 0, 0, .01); + --black-alpha-2: rgba(0, 0, 0, .02); + --black-alpha-3: rgba(0, 0, 0, .03); + --black-alpha-4: rgba(0, 0, 0, .04); + --black-alpha-5: rgba(0, 0, 0, .05); + --black-alpha-6: rgba(0, 0, 0, .06); + --black-alpha-7: rgba(0, 0, 0, .07); + --black-alpha-8: rgba(0, 0, 0, .08); + --black-alpha-10: rgba(0, 0, 0, .10); + --black-alpha-12: rgba(0, 0, 0, .12); + --black-alpha-16: rgba(0, 0, 0, .16); + --black-alpha-20: rgba(0, 0, 0, .20); + --black-alpha-24: rgba(0, 0, 0, .24); + --black-alpha-32: rgba(38, 38, 38, .32); + --black-alpha-40: rgba(38, 38, 38, .40); + --black-alpha-48: rgba(38, 38, 38, .48); + --black-alpha-56: rgba(38, 38, 38, .56); + --black-alpha-64: rgba(38, 38, 38, .64); + --black-alpha-72: rgba(38, 38, 38, .72); + --black-alpha-88: rgba(38, 38, 38, .88); + + /* ===== Radius ===== */ + --r-sm: 4px; + --r-md: 8px; + --r-pill: 999px; + + /* ===== Font · 关键:--font-mono 必须含 PuHuiTi 中文 fallback ===== */ + /* Inter 不含 CJK 字形。 + 字体链按字符级 fallthrough,中文字符会找下一个候选 —— 必须包含 PuHuiTi。 */ + --font-sans: 'Inter', 'Alibaba PuHuiTi', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + --font-inter: 'Inter', system-ui, sans-serif; + --font-mono: 'Inter', 'Alibaba PuHuiTi', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + + /* ===== Transition ===== */ + --t-fast: 100ms ease; + --t-base: 200ms ease; + --t-slow: 300ms cubic-bezier(0.34, 1.56, 0.64, 1); + + /* ===== Shadows ===== */ + --shadow-cta: + inset 0 -4px 8px rgba(250, 93, 25, .20), + 0 1px 1px rgba(250, 93, 25, .12), + 0 2px 4px rgba(250, 93, 25, .10), + 0 .5px .5px rgba(250, 93, 25, .16); + --shadow-cta-hover: + inset 0 -4px 8px rgba(250, 93, 25, .20), + 0 1px 1px rgba(250, 93, 25, .16), + 0 4px 8px rgba(250, 93, 25, .20), + 0 .5px .5px rgba(250, 93, 25, .16); + --shadow-cta-active: + inset 0 -4px 8px rgba(250, 93, 25, .28), + 0 1px 2px rgba(250, 93, 25, .16); + --shadow-floating: 0 4px 20px rgba(21, 20, 15, .06); +} + +::selection { background: var(--heat-20); color: var(--heat); } + +html, body { + background: var(--background-base); + color: var(--accent-black); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +a { color: inherit; text-decoration: none; } +button { font: inherit; cursor: pointer; border: 0; background: none; color: inherit; } +input, textarea, select { font: inherit; color: inherit; outline: none; } +img, svg, video { display: block; max-width: 100%; } + +/* ─── Icon system · Lucide-line only ─── */ +:root { + --icon-stroke: 1.5; + --icon-xs: 12px; + --icon-s: 14px; + --icon-m: 16px; + --icon-l: 20px; + --icon-xl: 24px; +} +.ui-icon { + width: var(--icon-m); + height: var(--icon-m); + flex: 0 0 auto; + display: block; + fill: none; + stroke: currentColor; + stroke-width: var(--icon-stroke); + stroke-linecap: round; + stroke-linejoin: round; +} +.ui-icon * { vector-effect: non-scaling-stroke; } +.ui-icon [stroke="none"] { + fill: currentColor; + stroke: none; +} +:where(nav a, .search-box, .balance-chip, .queue-chip, .icon-btn, .btn, .chip, .toolbar, .page-head, .toast, .modal, .drawer, .card-del-btn, .row-more, .gen-img-btn, .shortcut, .empty-state) svg:not(.corner):not(.quote-icon) { + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: var(--icon-stroke); + stroke-linecap: round; + stroke-linejoin: round; +} +:where(nav a, .search-box, .balance-chip, .queue-chip, .icon-btn, .btn, .chip, .toolbar, .page-head, .toast, .modal, .drawer, .card-del-btn, .row-more, .gen-img-btn, .shortcut, .empty-state) svg:not(.corner):not(.quote-icon) [fill="currentColor"] { + fill: none; + stroke: currentColor; +} +:where(nav a, .search-box, .balance-chip, .queue-chip, .icon-btn, .btn, .chip, .toolbar, .page-head, .toast, .modal, .drawer, .card-del-btn, .row-more, .gen-img-btn, .shortcut, .empty-state) svg:not(.corner):not(.quote-icon) [stroke="none"] { + fill: currentColor; + stroke: none; +} +.row-more svg circle { + fill: currentColor !important; + stroke: none !important; +} + +.num, .tnum { font-variant-numeric: tabular-nums; } +.mono { font-family: var(--font-mono); } +.muted { color: var(--black-alpha-56); } +.muted-2 { color: var(--black-alpha-48); } +.spacer { flex: 1; } +.hstack { display: flex; align-items: center; gap: 8px; } +.hstack[hidden], .vstack[hidden] { display: none; } +.vstack { display: flex; flex-direction: column; gap: 8px; } +.divider { height: 1px; background: var(--border-faint); margin: 16px 0; } + +/* ─── App shell ─── */ +.app { + display: grid; + grid-template-columns: 248px 1fr; + min-height: 100vh; + transition: grid-template-columns var(--t-base); +} +body.sidebar-collapsed .app { grid-template-columns: 96px 1fr; } + +/* ─── Sidebar ─── */ +aside.sidebar { + padding: 22px 16px; + border-right: 1px solid var(--border-faint); + background: var(--background-base); + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + transition: padding var(--t-base); +} +.sidebar-head { + display: flex; + align-items: center; + padding: 2px 8px 16px; + min-height: 44px; +} +.brand { display: flex; align-items: center; min-width: 0; color: var(--accent-black); } +.brand-clip { + display: block; + width: 142px; + overflow: hidden; + transition: width var(--t-base); +} +.brand-logo { display: block; width: 142px; max-width: none; height: auto; margin: -8px 0 -6px -8px; object-fit: contain; } +.brand-mark, .flame { width: 22px; height: 22px; color: var(--heat); } +.brand-mark svg, .flame svg { width: 100%; height: 100%; } +.brand .name { font-weight: 600; font-size: 18px; letter-spacing: -.012em; color: var(--accent-black); } +.sidebar-toggle { + position: fixed; + top: 0; + left: 220px; + z-index: 70; + width: 28px; + height: 100vh; + border: 0; + border-radius: 0; + background: transparent; + color: var(--black-alpha-48); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 1; + pointer-events: auto; + transition: color var(--t-base), background var(--t-base); +} +.sidebar-toggle:hover, +.sidebar-toggle:focus-visible { + background: var(--black-alpha-4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + color: var(--accent-black); + outline: none; +} +.sidebar-toggle-icon { + display: block; + opacity: 0; + line-height: 1; + transition: opacity var(--t-base); +} +.sidebar-toggle:hover .sidebar-toggle-icon, +.sidebar-toggle:focus-visible .sidebar-toggle-icon { opacity: 1; } +.sidebar-toggle-icon--expand { display: none; } + +/* sidebar search · Ctrl K Inter Bold 平铺 */ +.search-box { + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + color: var(--black-alpha-48); + margin-bottom: 18px; + cursor: text; + transition: border-color var(--t-base); +} +.search-box:hover { border-color: var(--black-alpha-24); } +.search-box:focus-within { border-color: var(--heat-40); box-shadow: inset 0 0 0 1px var(--heat-40); } +.search-box svg { width: var(--icon-m); height: var(--icon-m); flex-shrink: 0; color: var(--black-alpha-56); } +.search-box input { + flex: 1; min-width: 0; + border: 0; background: transparent; + font-size: 13.5px; color: var(--accent-black); + padding: 0; +} +.search-box input::placeholder { color: var(--black-alpha-48); } +.search-box .kbd { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-inter); + font-weight: 700; + font-size: 11px; + color: var(--black-alpha-48); + letter-spacing: .02em; +} + +.nav-section { + font-size: 11px; color: var(--black-alpha-48); + padding: 16px 12px 8px; + letter-spacing: .04em; + font-weight: 500; + /* 中文标签用 sans 字体,不用 mono + uppercase */ +} +nav { display: flex; flex-direction: column; gap: 2px; } +nav a { + display: flex; align-items: center; gap: 11px; + padding: 9px 12px; + color: var(--black-alpha-56); + font-size: 13.5px; font-weight: 500; + border-radius: var(--r-md); + cursor: pointer; + user-select: none; + transition: background var(--t-base), color var(--t-base); +} +nav a:hover { background: var(--black-alpha-4); color: var(--accent-black); } +nav a.active { background: var(--heat-12); color: var(--heat); } +nav a svg { width: var(--icon-m); height: var(--icon-m); opacity: .85; } +nav a.active svg { opacity: 1; } +nav a .pill-mini { + margin-left: auto; + font-family: var(--font-mono); + font-size: 9.5px; font-weight: 600; + padding: 2px 7px; + background: var(--surface); + color: var(--black-alpha-48); + border: 1px solid var(--border-faint); + border-radius: var(--r-pill); + letter-spacing: .04em; +} +nav a.disabled { color: var(--black-alpha-32); cursor: not-allowed; } +nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); } +.aside-foot { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-faint); } +.user { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; + border-radius: var(--r-md); + cursor: pointer; + transition: background var(--t-base); +} +.user:hover { background: var(--black-alpha-4); } +.user .av { + width: 26px; height: 26px; + border-radius: 6px; + background: var(--accent-black); + color: var(--accent-white); + display: flex; align-items: center; justify-content: center; + font-weight: 600; font-size: 11px; +} +.user .em { font-size: 13px; color: var(--accent-black); } + +body.sidebar-collapsed aside.sidebar { padding: 22px 12px; } +body.sidebar-collapsed .sidebar-head { gap: 6px; padding: 2px 0 16px; } +body.sidebar-collapsed .brand-clip { + width: 34px; + height: 34px; + display: flex; + align-items: center; +} +body.sidebar-collapsed .sidebar-toggle { left: 68px; } +body.sidebar-collapsed .sidebar-toggle-icon--collapse { display: none; } +body.sidebar-collapsed .sidebar-toggle-icon--expand { display: block; } +body.sidebar-collapsed .search-box { + justify-content: center; + gap: 0; + padding: 9px 0; + cursor: pointer; +} +body.sidebar-collapsed .search-box input, +body.sidebar-collapsed .search-box .kbd, +body.sidebar-collapsed aside.sidebar nav a span, +body.sidebar-collapsed aside.sidebar nav a .pill-mini, +body.sidebar-collapsed .user .em { display: none; } +body.sidebar-collapsed .nav-section { + height: 1px; + margin: 10px 0 8px; + padding: 0; + overflow: hidden; + color: transparent; + background: var(--border-faint); +} +body.sidebar-collapsed aside.sidebar nav a { + justify-content: center; + gap: 0; + padding: 10px 0; +} +body.sidebar-collapsed .aside-foot { padding-top: 12px; } +body.sidebar-collapsed .user { + justify-content: center; + padding: 8px 0; +} + +/* ─── Main + grid background ─── */ +main { position: relative; background: var(--background-base); min-width: 0; } +.grid-bg { + position: absolute; inset: 0; pointer-events: none; + background-image: + url("data:image/svg+xml;utf8,"), + url("data:image/svg+xml;utf8,"), + url("data:image/svg+xml;utf8,"); + background-size: 240px 240px, 60px 60px, 240px 240px; + mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%); + -webkit-mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%); +} +.scatter { + position: absolute; + font-family: var(--font-mono); font-size: 8.5px; line-height: 1.05; + color: var(--black-alpha-20); white-space: pre; pointer-events: none; + opacity: .85; letter-spacing: .04em; +} +.tag-corner { + position: absolute; + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 10.5px; letter-spacing: .06em; + pointer-events: none; opacity: .85; z-index: 1; +} +.sq-mark { position: absolute; width: 5px; height: 5px; background: var(--black-alpha-24); pointer-events: none; } + +/* ─── Topbar ─── */ +.topbar { + display: flex; align-items: center; gap: 12px; + padding: 14px 28px; + border-bottom: 1px solid var(--border-faint); + background: var(--background-base); + position: sticky; top: 0; z-index: 50; +} +.crumbs { display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--black-alpha-48); } +.crumbs .sep { color: var(--black-alpha-32); } +.crumbs .here { color: var(--accent-black); font-weight: 500; } +.crumbs a:hover { color: var(--accent-black); } +.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.balance-chip { + display: inline-flex; align-items: center; gap: 7px; + height: 36px; + padding: 0 14px 0 12px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-pill); + font-size: 13px; + color: var(--black-alpha-56); + cursor: pointer; + transition: background var(--t-base), border-color var(--t-base); +} +.balance-chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); } +.balance-chip strong { color: var(--accent-black); font-weight: 600; font-variant-numeric: tabular-nums; } +.balance-chip svg { width: var(--icon-m); height: var(--icon-m); color: var(--heat); } +.icon-btn { + width: 36px; height: 36px; + display: flex; align-items: center; justify-content: center; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-pill); + color: var(--black-alpha-56); + cursor: pointer; + position: relative; + transition: background var(--t-base), border-color var(--t-base), color var(--t-base); +} +.icon-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); border-color: var(--black-alpha-24); } +.icon-btn svg { width: var(--icon-m); height: var(--icon-m); } +.icon-btn .dot-noti { + position: absolute; top: 8px; right: 9px; + width: 7px; height: 7px; border-radius: 50%; + background: var(--heat); border: 1.5px solid var(--surface); +} +.icon-btn .count-noti { + position: absolute; top: -4px; right: -4px; + min-width: 16px; height: 16px; padding: 0 4px; + display: inline-flex; align-items: center; justify-content: center; + background: var(--accent-crimson); color: var(--accent-white); + border: 1.5px solid var(--surface); + border-radius: var(--r-pill); + font-family: var(--font-mono); font-size: 9.5px; font-weight: 600; + letter-spacing: .02em; line-height: 1; + pointer-events: none; +} + +/* ─── Topbar · 任务队列 chip ─── */ +.queue-chip { + display: inline-flex; align-items: center; gap: 7px; + height: 36px; + padding: 0 8px 0 12px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-pill); + font-size: 13px; + color: var(--accent-black); + cursor: pointer; + transition: background var(--t-base), border-color var(--t-base); +} +.queue-chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); } +.queue-chip[hidden] { display: none; } +.queue-chip svg { width: var(--icon-m); height: var(--icon-m); color: var(--black-alpha-56); } +.queue-chip .count { + display: inline-flex; align-items: center; justify-content: center; + height: 20px; min-width: 20px; padding: 0 6px; + background: var(--heat-12); color: var(--heat); + border: 1px solid var(--heat-20); + border-radius: var(--r-pill); + font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; + letter-spacing: .02em; +} + +/* ─── Topbar · 头像 ─── */ +.topbar-avatar { + width: 36px; height: 36px; + border-radius: var(--r-pill); + background: var(--accent-black); + color: var(--accent-white); + display: inline-flex; align-items: center; justify-content: center; + font-size: 13px; font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-faint); + transition: transform var(--t-fast), box-shadow var(--t-base); + flex-shrink: 0; + overflow: hidden; +} +.topbar-avatar:hover { + transform: scale(1.04); + box-shadow: 0 0 0 3px var(--heat-12); +} +.topbar-avatar img { + width: 100%; height: 100%; object-fit: cover; +} + +/* ─── Content ─── */ +.content { + padding: 48px 28px 72px; + position: relative; + z-index: 1; + min-height: calc(100vh - 64px); +} + +.content > .corner-mark { display: none; } +.page-head { + display: flex; align-items: flex-start; justify-content: space-between; + margin-bottom: 36px; gap: 16px; flex-wrap: wrap; +} +.page-head h1 { + font-size: 28px; font-weight: 600; letter-spacing: -.02em; line-height: 1.25; + color: var(--accent-black); +} +.page-head .sub { + font-size: 14px; color: var(--black-alpha-56); + margin-top: 10px; + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; +} +.page-head .sub .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .04em; } +.page-head .actions { display: flex; gap: 10px; align-items: center; } +/* page-head 右上角主操作按钮 · 统一尺寸(对齐 .btn-lg),覆盖各页里 btn / btn-sm / btn-lg 混用 */ +.page-head .actions > .btn, +.page-head .actions > a.btn, +.page-head .actions > button.btn { + height: 40px; + padding: 0 20px; + font-size: 13.5px; +} +.page-head .actions > .btn svg { width: var(--icon-s); height: var(--icon-s); } +.page-head .actions > .btn.btn-create svg { width: var(--icon-m); height: var(--icon-m); } + +.section-h { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; } +.section-h h2 { font-size: 16px; font-weight: 600; letter-spacing: -.01em; color: var(--accent-black); } +.section-h .more { + font-family: var(--font-mono); font-size: 11.5px; + color: var(--black-alpha-48); letter-spacing: .04em; + cursor: pointer; + transition: color var(--t-base); +} +.section-h .more:hover { color: var(--heat); } + +/* ─── Buttons · 统一高度 36 / 8 px 圆角 / 真 1px border / hover bg 切换 ─── */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + height: 36px; + padding: 0 16px; + border-radius: var(--r-md); + font-size: 13px; font-weight: 500; + background: var(--surface); + color: var(--accent-black); + cursor: pointer; + white-space: nowrap; + font-family: inherit; + border: 1px solid var(--black-alpha-12); + transition: background var(--t-base), border-color var(--t-base), transform var(--t-fast); +} +.btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); } +.btn:active { background: var(--black-alpha-7); transform: scale(.99); } +.btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40); } +.btn:disabled, .btn.disabled { + color: var(--black-alpha-32); + background: var(--black-alpha-5); + border-color: var(--black-alpha-12); + cursor: not-allowed; + transform: none; +} +.btn:disabled:hover, .btn.disabled:hover { + background: var(--black-alpha-5); + border-color: var(--black-alpha-12); +} +.btn svg { width: var(--icon-s); height: var(--icon-s); } +.btn[hidden] { display: none; } + +.btn-primary { + background: var(--heat); + color: var(--accent-white); + border-color: var(--heat); + font-weight: 600; + /* 继承 .btn 的 height: 36px / padding: 0 16px */ + box-shadow: var(--shadow-cta); +} +.btn-primary:hover { + background: var(--heat); + border-color: var(--heat); + box-shadow: var(--shadow-cta-hover); +} +.btn-primary:active { + background: var(--heat); + transform: scale(.995); + box-shadow: var(--shadow-cta-active); +} +.btn-primary:disabled, .btn-primary.disabled { + background: var(--heat-40); + color: var(--accent-white); + border-color: var(--heat-40); + box-shadow: none; +} + +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--black-alpha-56); +} +.btn-ghost:hover { background: var(--black-alpha-4); border-color: transparent; color: var(--accent-black); } +.btn-ghost:active { background: var(--black-alpha-7); } + +.btn-sm { height: 28px; padding: 0 12px; font-size: 12px; border-radius: var(--r-md); } +.btn-lg { height: 40px; padding: 0 20px; font-size: 13.5px; } + +/* ─── Pills ─── */ +.pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; + border-radius: var(--r-pill); + font-size: 11.5px; font-weight: 500; + border: 1px solid var(--border-faint); + background: var(--surface); + color: var(--black-alpha-56); + white-space: nowrap; +} +.pill.info { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); } +.pill.ok { background: var(--forest-bg); color: var(--accent-forest); border-color: var(--forest-bd); } +.pill.err { background: var(--crimson-bg); color: var(--accent-crimson); border-color: var(--crimson-bd); } +.pill.neutral { background: var(--black-alpha-4); color: var(--black-alpha-56); border-color: var(--border-faint); } +.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } + +/* ─── Cards / containers · V2.1 统一 8 px 圆角 ─── */ +.card-hard { + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + position: relative; +} +/* V2.1 spec §5.3 · 容器四角准星 · SVG 圆弧内凹(22×21 viewBox) */ +.card-hard.with-corners::before, .card-hard.with-corners::after, +.with-corners .corner-tr, .with-corners .corner-bl { + 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; +} +.card-hard.with-corners::before { top: -7px; left: -7px; } +.card-hard.with-corners::after { bottom: -7px; right: -7px; } +.with-corners .corner-tr { top: -7px; right: -7px; } +.with-corners .corner-bl { bottom: -7px; left: -7px; } + +/* ─── Stats (KPI bar) ─── */ +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + position: relative; + margin-bottom: 36px; + overflow: hidden; +} +.stat { + padding: 24px 28px; + border-right: 1px solid var(--border-faint); + position: relative; + cursor: pointer; + transition: background var(--t-base); +} +.stat:hover { background: var(--black-alpha-4); } +.stat:last-child { border-right: 0; } +.stat .lbl { + font-size: 12.5px; color: var(--black-alpha-48); + font-weight: 500; display: flex; align-items: center; gap: 8px; +} +.stat .lbl .badge { + font-family: var(--font-mono); + font-size: 10px; color: var(--black-alpha-48); + background: var(--black-alpha-4); + padding: 1px 7px; + border-radius: var(--r-sm); + border: 1px solid var(--border-faint); + letter-spacing: .04em; +} +.stat .v { + font-size: 32px; font-weight: 600; letter-spacing: -.02em; + line-height: 1.1; margin-top: 14px; + font-variant-numeric: tabular-nums; + color: var(--accent-black); +} +.stat .v small { font-size: 15px; color: var(--black-alpha-48); font-weight: 500; margin-left: 2px; } +.stat .delta { + font-family: var(--font-mono); font-size: 11px; + margin-top: 10px; color: var(--black-alpha-48); letter-spacing: .02em; +} +.stat .delta.up { color: var(--accent-forest); } +.stat .bar { height: 5px; background: var(--black-alpha-7); border-radius: 3px; margin-top: 14px; overflow: hidden; } +.stat .bar > span { display: block; height: 100%; background: var(--heat); border-radius: 3px; } +.stat .sub { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; +} + +/* ─── Form fields ─── */ +.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; } +.field-label { font-size: 13px; font-weight: 500; color: var(--accent-black); } +.field-label .req { color: var(--accent-crimson); margin-left: 2px; } +.field-hint { font-size: 12px; color: var(--black-alpha-48); } +.input, .textarea, .select { + height: 36px; + padding: 0 14px; + background: var(--surface); + border: 1px solid var(--black-alpha-12); + border-radius: var(--r-md); + font-size: 13.5px; + width: 100%; + font-family: inherit; + color: var(--accent-black); + transition: border-color var(--t-base), box-shadow var(--t-base), background var(--t-base); +} +.input:hover, .textarea:hover, .select:hover { border-color: var(--black-alpha-24); } +.input:focus, .textarea:focus, .select:focus { + border-color: var(--heat-40); + box-shadow: inset 0 0 0 1px var(--heat-40); +} +.input::placeholder, .textarea::placeholder { color: var(--black-alpha-48); } +.input:disabled, .textarea:disabled, .select:disabled { + background: var(--black-alpha-5); + color: var(--black-alpha-32); + cursor: not-allowed; +} +.textarea { height: auto; min-height: 88px; padding: 12px 14px; line-height: 1.6; resize: vertical; } +.select { + appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; +} + +/* ─── Tabs ─── */ +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border-faint); margin-bottom: 20px; } +.tab { + padding: 10px 14px; font-size: 13px; + color: var(--black-alpha-56); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + font-weight: 500; user-select: none; + transition: color var(--t-base), background var(--t-base); + border-radius: var(--r-md) var(--r-md) 0 0; +} +.tab:hover { color: var(--accent-black); background: var(--black-alpha-4); } +.tab.active { color: var(--accent-black); border-bottom-color: var(--heat); } +.tab .count { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--black-alpha-48); margin-left: 6px; + padding: 1px 7px; + background: var(--black-alpha-4); + border-radius: var(--r-sm); letter-spacing: .04em; +} +.tab.active .count { background: var(--heat-12); color: var(--heat); } + +/* ─── Filter chips · 统一高度 36 px 与 .btn / .input 对齐 ─── */ +.chip { + height: 36px; padding: 0 14px; + border: 1px solid var(--border-faint); + background: var(--surface); + border-radius: var(--r-md); + font-size: 13px; + color: var(--black-alpha-56); + display: inline-flex; align-items: center; gap: 6px; + cursor: pointer; + font-family: inherit; + transition: background var(--t-base), border-color var(--t-base), color var(--t-base); +} +.chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); color: var(--accent-black); } +.chip.active { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); } +.chip svg { width: 12px; height: 12px; } + +/* ─── Toolbar ─── */ +.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; } +.toolbar .search-inline { + position: relative; flex: 1; max-width: 360px; +} +.toolbar .search-inline svg { + position: absolute; left: 12px; top: 50%; transform: translateY(-50%); + color: var(--black-alpha-56); width: 14px; height: 14px; + z-index: 2; pointer-events: none; +} +.toolbar .search-inline input { padding-left: 36px; } + +/* ─── Chip dropdown · 共享组件 ─── */ +.chip-wrap { position: relative; display: inline-flex; } +.chip-wrap .chip svg.caret { transition: transform .15s; } +.chip-wrap.open .chip svg.caret { transform: rotate(180deg); } +.chip-menu { + position: absolute; top: calc(100% + 4px); left: 0; + min-width: 200px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + box-shadow: var(--shadow-floating); + padding: 4px; + z-index: 50; + display: none; + max-height: 320px; + overflow-y: auto; +} +.chip-wrap.open .chip-menu { display: block; } +.chip-menu.align-right { left: auto; right: 0; } +.chip-menu .mi { + height: 32px; padding: 0 10px; + font-size: 13px; color: var(--accent-black); + display: flex; align-items: center; gap: 8px; + border-radius: var(--r-sm); + cursor: pointer; + white-space: nowrap; +} +.chip-menu .mi:hover { background: var(--black-alpha-4); } +.chip-menu .mi.selected { color: var(--heat); background: var(--heat-12); } +.chip-menu .mi .mi-check { width: 13px; height: 13px; color: var(--heat); opacity: 0; flex-shrink: 0; } +.chip-menu .mi.selected .mi-check { opacity: 1; } +.chip-menu .mi-sep { height: 1px; background: var(--border-faint); margin: 4px 6px; } + +/* ─── Dropdown unification · 下拉框 / 菜单视觉收口 ─── */ +select.select, +select.v-select, +select.nm-select, +select.duration-select, +.filter-bar select { + appearance: none !important; + height: 36px !important; + padding: 0 32px 0 14px !important; + background-color: var(--surface) !important; + background-image: url("data:image/svg+xml;utf8,") !important; + background-repeat: no-repeat !important; + background-position: right 12px center !important; + border: 1px solid var(--black-alpha-12) !important; + border-radius: var(--r-md) !important; + color: var(--accent-black) !important; + font-family: inherit !important; + font-size: 13.5px !important; + transition: border-color var(--t-base), box-shadow var(--t-base), background var(--t-base) !important; +} +select.select:hover, +select.v-select:hover, +select.nm-select:hover, +select.duration-select:hover, +.filter-bar select:hover { + border-color: var(--black-alpha-24) !important; +} +select.select:focus, +select.v-select:focus, +select.nm-select:focus, +select.duration-select:focus, +.filter-bar select:focus { + border-color: var(--heat-40) !important; + box-shadow: inset 0 0 0 1px var(--heat-40) !important; +} + +.pd-toolbar .filter, +.pp-toolbar .pp-chip, +.mp-main-h .tb-chip, +.pc-main-h .tb-chip { + height: 36px !important; + padding: 0 14px !important; + gap: 6px !important; + background: var(--surface) !important; + border: 1px solid var(--border-faint) !important; + border-radius: var(--r-md) !important; + color: var(--black-alpha-56) !important; + font-size: 13px !important; + font-weight: 500 !important; + transition: background var(--t-base), border-color var(--t-base), color var(--t-base) !important; +} +.pd-toolbar .filter:hover, +.pp-toolbar .pp-chip:hover, +.mp-main-h .tb-chip:hover, +.pc-main-h .tb-chip:hover { + background: var(--black-alpha-4) !important; + border-color: var(--black-alpha-24) !important; + color: var(--accent-black) !important; +} +.pd-toolbar .filter.open, +.pd-toolbar .filter.filtered, +.pp-toolbar .pp-chip.active, +.mp-main-h .tb-chip.active, +.pc-main-h .tb-chip.active { + background: var(--heat-12) !important; + border-color: var(--heat-40) !important; + color: var(--heat) !important; +} + +:where(.chip-menu, .filter-pop, .pp-menu, .tb-menu, .io-param-menu, .move-menu, .cell-more-menu, .msg-more-menu, .batch-more-menu) { + background: var(--surface) !important; + border: 1px solid var(--border-faint) !important; + border-radius: var(--r-md) !important; + box-shadow: var(--shadow-floating) !important; + padding: 4px !important; +} +:where( + .chip-menu .mi, + .filter-pop button, + .pp-menu .mi, + .tb-menu-item, + .io-param-menu .mi, + .move-menu .mv-item, + .cell-more-menu button, + .msg-more-menu button, + .batch-more-menu button +) { + min-height: 32px !important; + padding: 0 10px !important; + background: transparent !important; + border: 0 !important; + border-radius: var(--r-sm) !important; + color: var(--accent-black) !important; + display: flex !important; + align-items: center !important; + gap: 8px !important; + font-family: inherit !important; + font-size: 13px !important; + font-weight: 400 !important; + text-align: left !important; + cursor: pointer !important; + transition: background var(--t-base), color var(--t-base) !important; +} +:where( + .chip-menu .mi, + .filter-pop button, + .pp-menu .mi, + .tb-menu-item, + .io-param-menu .mi, + .move-menu .mv-item, + .cell-more-menu button, + .msg-more-menu button, + .batch-more-menu button +):hover { + background: var(--black-alpha-4) !important; + color: var(--accent-black) !important; +} +:where( + .chip-menu .mi.selected, + .filter-pop button.selected, + .pp-menu .mi.selected, + .tb-menu-item.active, + .io-param-menu .mi.selected +) { + background: var(--heat-12) !important; + color: var(--heat) !important; + font-weight: 500 !important; +} +:where(.cell-more-menu button.danger:hover, .msg-more-menu button.danger:hover, .batch-more-menu button.danger:hover) { + background: var(--crimson-bg) !important; + color: var(--accent-crimson) !important; +} +:where(.tb-menu-empty) { + color: var(--black-alpha-48) !important; + font-family: var(--font-mono) !important; + font-size: 11.5px !important; + letter-spacing: .02em !important; +} + +.rs-select { + position: relative; + display: inline-flex; + min-width: 126px; + vertical-align: middle; +} +.rs-select.rs-select-fill { width: 100%; } +.rs-select.rs-select-filter { min-width: 126px; } +.rs-select > select[data-rs-select-bound="1"] { + position: absolute !important; + inset: 0 auto auto 0 !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; + pointer-events: none !important; +} +.rs-select-btn { + width: 100%; + height: 36px; + padding: 0 12px 0 14px; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--surface); + border: 1px solid var(--black-alpha-12); + border-radius: var(--r-md); + color: var(--accent-black); + font-family: inherit; + font-size: 13.5px; + cursor: pointer; + transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), color var(--t-base); +} +.rs-select-btn:hover { + background: var(--black-alpha-4); + border-color: var(--black-alpha-24); +} +.rs-select.open .rs-select-btn, +.rs-select-btn:focus-visible { + border-color: var(--heat-40); + box-shadow: inset 0 0 0 1px var(--heat-40); +} +.rs-select-btn[disabled] { + background: var(--black-alpha-5); + color: var(--black-alpha-32); + cursor: not-allowed; +} +.rs-select-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rs-select-btn svg { + width: 12px; + height: 12px; + color: var(--black-alpha-56); + flex-shrink: 0; + transition: transform var(--t-base), color var(--t-base); +} +.rs-select.open .rs-select-btn svg { + transform: rotate(180deg); + color: var(--heat); +} +.rs-select-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 100%; + width: max-content; + max-width: min(260px, calc(100vw - 24px)); + max-height: 320px; + overflow-y: auto; + display: none; + z-index: 1600; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + box-shadow: var(--shadow-floating); + padding: 4px; +} +.rs-select-menu.align-right { + left: auto; + right: 0; +} +.rs-select.open .rs-select-menu { display: block; } +.rs-select-option { + width: 100%; + min-height: 32px; + padding: 0 10px; + display: flex; + align-items: center; + gap: 8px; + background: transparent; + border: 0; + border-radius: var(--r-sm); + color: var(--accent-black); + font-family: inherit; + font-size: 13px; + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: background var(--t-base), color var(--t-base); +} +.rs-select-option:hover, +.rs-select-option.is-active { + background: var(--black-alpha-4); +} +.rs-select-option.selected { + background: var(--heat-12); + color: var(--heat); + font-weight: 500; +} +.rs-select-option[disabled] { + color: var(--black-alpha-32); + cursor: not-allowed; +} +.rs-select-option .mi-check { + width: 13px; + height: 13px; + color: var(--heat); + opacity: 0; + flex-shrink: 0; +} +.rs-select-option.selected .mi-check { opacity: 1; } + +/* ─── Clear-filters btn · 共享组件 ─── */ +.clear-filters { + height: 36px; padding: 0 12px; + background: transparent; border: 0; border-radius: var(--r-md); + color: var(--black-alpha-56); font-size: 13px; font-family: inherit; + display: inline-flex; align-items: center; gap: 6px; cursor: pointer; + transition: background var(--t-base), color var(--t-base); +} +.clear-filters:hover { background: var(--black-alpha-4); color: var(--heat); } +.clear-filters svg { width: 13px; height: 13px; } +.clear-filters[hidden] { display: none; } + +/* ─── Result count · 共享组件 ─── */ +.result-meta { + font-family: var(--font-mono); + font-size: 11px; + color: var(--black-alpha-48); + margin-bottom: 14px; + letter-spacing: .04em; +} +.result-meta .count { color: var(--heat); font-weight: 600; } + +/* ─── 新建商品 Modal (Upload Form · 居中弹窗) ─── */ +.new-product-modal { + max-width: 1080px !important; + width: 94%; + max-height: 90vh; + display: flex; + flex-direction: column; +} +/* 左图右文 双栏 · 等宽 */ +.np-body-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; +} +.np-left, .np-right { min-width: 0; } +@media (max-width: 880px) { + .np-body-grid { grid-template-columns: 1fr; gap: 20px; } +} +.np-header { + padding: 18px 24px 16px; + border-bottom: 1px solid var(--border-faint); + display: flex; align-items: center; gap: 14px; + flex-shrink: 0; +} +.np-header .np-title-ic { + width: 36px; height: 36px; + background: var(--heat-12); + color: var(--heat); + border-radius: var(--r-md); + display: grid; place-items: center; + flex-shrink: 0; +} +.np-header .np-title-ic svg { width: 17px; height: 17px; } +.np-header h2 { + font-size: 16px; font-weight: 600; color: var(--accent-black); + margin: 0; +} +.np-header .np-mode-pill { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--heat); + background: var(--heat-12); + border: 1px solid var(--heat-20); + border-radius: var(--r-sm); + padding: 2px 8px; + letter-spacing: .04em; + font-weight: 500; +} +.np-header .np-x { + margin-left: auto; + width: 32px; height: 32px; + display: grid; place-items: center; + background: transparent; border: 0; + border-radius: var(--r-md); + color: var(--black-alpha-56); + cursor: pointer; + transition: background var(--t-base), color var(--t-base); +} +.np-header .np-x:hover { background: var(--crimson-bg); color: var(--accent-crimson); } +.np-header .np-x svg { width: 14px; height: 14px; } + +.np-body { + flex: 1; + overflow-y: auto; + padding: 22px 24px; +} + +/* AI CTA · 独立全宽 CTA 区域 · 放在"商品图册"上方 */ +.np-ai-cta { + display: flex; align-items: center; gap: 10px; + width: 100%; + height: 40px; + padding: 0 14px; + margin-bottom: 14px; + background: transparent; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + font-size: 13px; + color: var(--black-alpha-72); + cursor: pointer; + text-decoration: none; + font-family: inherit; + transition: background var(--t-base), border-color var(--t-base), color var(--t-base); +} +.np-ai-cta:hover { background: var(--heat-8); border-color: var(--heat); color: var(--accent-black); } +.np-ai-cta .ai-icon { color: var(--heat); display: grid; place-items: center; flex-shrink: 0; } +.np-ai-cta .ai-icon svg { width: 14px; height: 14px; } +.np-ai-cta .ai-label { font-weight: 500; color: var(--accent-black); flex: 1; } +.np-ai-cta .ai-arrow { color: var(--black-alpha-48); transition: transform var(--t-fast), color var(--t-base); flex-shrink: 0; display: grid; place-items: center; } +.np-ai-cta .ai-arrow svg { width: 14px; height: 14px; } +.np-ai-cta:hover .ai-arrow { color: var(--heat); transform: translateX(2px); } +/* 主推态: 用户还没上传图 → 醒目橙色描边 */ +.np-ai-cta.primary { border-color: var(--heat-40); background: var(--heat-8); } +.np-ai-cta.primary .ai-label { color: var(--heat); font-weight: 600; } +.np-ai-cta.primary .ai-arrow { color: var(--heat); } +.np-ai-cta.primary:hover { background: var(--heat-12); border-color: var(--heat); } + +/* Footer (sticky inside modal) */ +.np-footer { + border-top: 1px solid var(--border-faint); + padding: 14px 24px; + display: flex; align-items: center; gap: 12px; + flex-shrink: 0; + background: var(--background-lighter); +} +.np-footer .np-meta { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--black-alpha-48); + letter-spacing: .02em; + margin-right: auto; +} +.np-footer .np-meta .accent { color: var(--heat); font-weight: 600; } + +/* Upload zone · 弹窗内尺寸略缩 */ +.np-body .upload-zone { + border: 1.5px dashed var(--black-alpha-24); + border-radius: var(--r-md); + padding: 22px 20px; + text-align: center; + background: var(--background-lighter); + color: var(--black-alpha-56); + font-size: 13px; + cursor: pointer; + display: flex; flex-direction: column; align-items: center; gap: 4px; + transition: border-color var(--t-base), background var(--t-base), color var(--t-base); +} +.np-body .upload-zone:hover { border-color: var(--heat); background: var(--heat-8); color: var(--heat); } +.np-body .upload-zone:hover .uz-ic { background: var(--heat); color: #fff; border-color: var(--heat); } +.np-body .upload-zone strong { color: var(--heat); font-weight: 600; } +.np-body .upload-zone .uz-ic { + width: 40px; height: 40px; + border-radius: var(--r-md); + background: var(--surface); + color: var(--heat); + border: 1px solid var(--heat-20); + display: grid; place-items: center; + margin-bottom: 8px; + transition: background var(--t-base), color var(--t-base), border-color var(--t-base); +} +.np-body .upload-zone .uz-ic svg { width: 18px; height: 18px; } +.np-body .upload-zone .uz-hint { display: block; margin-top: 2px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; } +/* 上传子区域标题 (图片案例 / 我的上传) */ +.np-section-h { + display: flex; align-items: center; gap: 6px; + font-size: 12px; font-weight: 500; + color: var(--accent-black); + margin: 12px 0 4px; +} +.np-section-h .check-ic { + width: 14px; height: 14px; + border-radius: 50%; + background: var(--accent-forest); + color: #fff; + display: grid; place-items: center; + flex-shrink: 0; +} +.np-section-h .check-ic svg { width: 8px; height: 8px; } +.np-section-h .counter { + margin-left: auto; + font-family: var(--font-mono); + font-size: 11px; + color: var(--black-alpha-48); + letter-spacing: .04em; + font-weight: 400; +} +.np-section-h .counter .num { color: var(--heat); font-weight: 600; } +.np-section-sub { + font-size: 11.5px; + color: var(--black-alpha-56); + margin-bottom: 8px; + line-height: 1.5; +} + +/* 图片案例 grid (静态展示) */ +.np-examples { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 5px; +} +.np-examples .ex { + aspect-ratio: 1; + border-radius: var(--r-sm); + background: var(--background-lighter); + border: 1px solid var(--border-faint); + position: relative; + display: grid; + place-items: center; + font-size: 9.5px; + color: var(--black-alpha-48); + font-family: var(--font-mono); + letter-spacing: .04em; + text-align: center; + padding: 4px; + overflow: hidden; +} +.np-examples .ex-badge { + position: absolute; + bottom: 3px; right: 3px; + width: 15px; height: 15px; + background: var(--accent-forest); + color: #fff; + border-radius: 50%; + display: grid; place-items: center; + z-index: 2; +} +.np-examples .ex-badge svg { width: 9px; height: 9px; } + +/* 我的上传 grid · 始终 5 列, 含填充和空插槽 */ +.np-body .upload-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 5px; +} +.up-slot-empty { + aspect-ratio: 1; + border-radius: var(--r-sm); + background: var(--background-lighter); + border: 1px dashed var(--border-faint); + cursor: pointer; + display: grid; + place-items: center; + font-size: 9.5px; + color: var(--black-alpha-32); + font-family: var(--font-mono); + letter-spacing: .04em; + transition: border-color var(--t-base), background var(--t-base), color var(--t-base); +} +.up-slot-empty:hover { + border-color: var(--heat); + background: var(--heat-8); + color: var(--heat); +} + +/* dropzone 满 5 张时禁用态 */ +.np-body .upload-zone.full { + cursor: not-allowed; + opacity: .55; + pointer-events: none; +} +.np-body .upload-zone.full .uz-ic { background: var(--background-lighter); color: var(--black-alpha-32); border-color: var(--border-faint); } +.up-thumb { + aspect-ratio: 1; + border-radius: var(--r-md); + overflow: hidden; + position: relative; + background: var(--background-lighter); + border: 1px solid var(--border-faint); + cursor: zoom-in; + transition: transform var(--t-fast), border-color var(--t-base); +} +.up-thumb:hover { transform: scale(1.02); border-color: var(--heat); } +.up-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.up-thumb .slot-x { + position: absolute; + top: 3px; right: 3px; + width: 18px; height: 18px; + background: rgba(0, 0, 0, .7); + color: #fff; + border-radius: 50%; + display: none; + place-items: center; + cursor: pointer; + z-index: 3; + border: 0; + padding: 0; + transition: background var(--t-base), transform var(--t-fast); +} +.up-thumb:hover .slot-x { display: grid; } +.up-thumb .slot-x:hover { background: var(--accent-crimson); transform: scale(1.1); } +.up-thumb .slot-x svg { width: 9px; height: 9px; } +/* 5 列尺寸太小,文件名 overlay 仅在 lightbox 显示,缩略图上隐藏 */ +.up-thumb .slot-name { display: none; } + +/* Lightbox: 点击缩略图全屏预览 */ +.np-lightbox { + position: fixed; inset: 0; + background: rgba(0, 0, 0, .9); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; + cursor: zoom-out; + opacity: 0; + transition: opacity .2s; +} +.np-lightbox.show { display: flex; opacity: 1; } +.np-lightbox img { + max-width: 90vw; + max-height: 88vh; + border-radius: var(--r-md); + box-shadow: 0 20px 60px rgba(0, 0, 0, .5); +} +.np-lightbox .lb-x { + position: fixed; + top: 24px; right: 24px; + width: 44px; height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, .12); + color: #fff; + border: 0; + cursor: pointer; + display: grid; + place-items: center; + transition: background var(--t-base); +} +.np-lightbox .lb-x:hover { background: rgba(255, 255, 255, .24); } +.np-lightbox .lb-x svg { width: 18px; height: 18px; } +.np-lightbox .lb-name { + position: fixed; + bottom: 24px; left: 50%; + transform: translateX(-50%); + font-family: var(--font-mono); + font-size: 12px; + color: rgba(255, 255, 255, .7); + letter-spacing: .04em; +} + +/* Bullet list · 弹窗内 (复用样式) */ +.np-body .bullet-list { list-style: none; padding: 0; } +.np-body .bullet-list li { + display: flex; gap: 10px; align-items: center; + padding: 10px 12px; + background: var(--background-lighter); + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + margin-bottom: 6px; + font-size: 13px; + color: var(--accent-black); + transition: border-color var(--t-base), background var(--t-base); +} +.np-body .bullet-list li.bl-item:hover { border-color: var(--black-alpha-24); } +.np-body .bullet-list li.bl-add { background: var(--surface); border-style: dashed; } +.np-body .bullet-list li.bl-add:focus-within { border-color: var(--heat-40); } +.np-body .bullet-list .num { + width: 20px; height: 20px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + font-size: 11px; color: var(--black-alpha-56); + display: grid; place-items: center; + flex-shrink: 0; + font-family: var(--font-mono); + font-weight: 600; +} +.np-body .bullet-list li.bl-add .num { background: transparent; color: var(--heat); border-color: var(--heat-40); } +.np-body .bullet-list .bl-text { flex: 1; min-width: 0; } +.np-body .bullet-list .bl-input { + flex: 1; min-width: 0; + height: 24px; border: 0; padding: 0 4px; + background: transparent; font-size: 13px; + color: var(--accent-black); font-family: inherit; + outline: none; +} +.np-body .bullet-list .bl-input::placeholder { color: var(--black-alpha-48); } +.np-body .bullet-list .bl-x { + width: 24px; height: 24px; + display: grid; place-items: center; + color: var(--black-alpha-32); + border-radius: var(--r-sm); + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity var(--t-base), background var(--t-base), color var(--t-base); +} +.np-body .bullet-list li.bl-item:hover .bl-x { opacity: 1; } +.np-body .bullet-list .bl-x:hover { background: var(--crimson-bg); color: var(--accent-crimson); } +.np-body .bullet-list .bl-x svg { width: 11px; height: 11px; } + +/* ─── Empty state · 共享组件 ─── */ +.empty-state { + background: var(--surface); + border: 1px dashed var(--border-faint); + border-radius: var(--r-md); + padding: 80px 40px; + text-align: center; + color: var(--black-alpha-56); + display: none; +} +.empty-state.show { display: block; } +.empty-state .ic-empty { + width: 48px; height: 48px; + margin: 0 auto 14px; + background: var(--background-lighter); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + display: grid; place-items: center; + color: var(--black-alpha-48); +} +.empty-state h3 { font-size: 14px; font-weight: 600; color: var(--accent-black); margin-bottom: 6px; } +.empty-state p { font-size: 12.5px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; } + +/* ─── Progress (5 段流水线 · V2.1 语义色 + 脉动) ─── */ +.prog { display: flex; gap: 3px; } +.prog span { + width: 18px; height: 5px; border-radius: 2px; + background: var(--black-alpha-8); + transition: background var(--t-base); +} +.prog span.done { background: var(--accent-forest); } +.prog span.cur { + background: var(--heat); + animation: prog-pulse 1.4s ease-in-out infinite; +} +.prog span.fail { background: var(--accent-crimson); } +@keyframes prog-pulse { + 0%, 100% { opacity: 1; transform: scaleY(1); } + 50% { opacity: .55; transform: scaleY(.7); } +} + +/* ─── Table ─── */ +table.t { + width: 100%; border-collapse: separate; border-spacing: 0; + background: var(--surface); + border: 1px solid var(--border-muted); + border-radius: var(--r-md); + overflow: hidden; +} +table.t thead th { + text-align: left; + font-size: 11.5px; + font-weight: 500; + color: var(--black-alpha-48); + padding: 14px 16px; + background: var(--black-alpha-3); + border-bottom: 1px solid var(--border-muted); + letter-spacing: .04em; + text-transform: uppercase; + font-family: var(--font-mono); +} +table.t tbody td { + padding: 16px; + border-bottom: 0; + font-size: 13px; + vertical-align: middle; + color: var(--accent-black); +} +table.t tbody tr { cursor: pointer; transition: background var(--t-base); } +table.t tbody tr:hover { background: var(--black-alpha-4); } + +/* ─── Placeholder thumb ─── */ +.placeholder { + background: + repeating-linear-gradient(135deg, rgba(0,0,0,0.025) 0 1px, transparent 1px 12px), + var(--black-alpha-4); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + display: grid; place-items: center; + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: .04em; + user-select: none; + overflow: hidden; + position: relative; + text-align: center; + padding: 6px; +} +.placeholder .ph-frame { + background: rgba(255, 255, 255, .92); + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + padding: 3px 8px; + font-size: 10.5px; + color: var(--black-alpha-56); + font-weight: 500; +} +.placeholder.has-mock-media { + background-color: var(--background-lighter); + background-image: var(--mock-media-url); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + padding: 0; +} +.placeholder.has-mock-media .ph-frame { + opacity: 0; + pointer-events: none; +} +.placeholder.has-mock-media.mock-label .ph-frame { + position: absolute; + left: 8px; + bottom: 8px; + max-width: calc(100% - 16px); + opacity: 1; +} + +/* ─── Toast ─── */ +.toast { + position: fixed; bottom: 24px; right: 24px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + padding: 14px 18px; + display: flex; align-items: center; gap: 12px; + box-shadow: var(--shadow-floating); + transform: translateX(420px); + transition: transform var(--t-slow); + z-index: 1000; + min-width: 260px; +} +.toast.show { transform: translateX(0); } +.toast .ic-t { + width: 26px; height: 26px; + background: var(--heat-12); + color: var(--heat); + border-radius: var(--r-md); + display: grid; place-items: center; + flex-shrink: 0; +} +.toast .ic-t svg { width: 13px; height: 13px; } +.toast .txt { font-size: 13px; color: var(--accent-black); font-weight: 500; } +.toast .txt .mono { + font-family: var(--font-mono); font-size: 11px; + color: var(--black-alpha-48); display: block; margin-top: 2px; + letter-spacing: .04em; font-weight: 400; +} + +/* ─── Global command palette + account menu ─── */ +.shell-command-bg { + position: fixed; + inset: 0; + z-index: 1800; + display: none; + align-items: flex-start; + justify-content: center; + padding: 12vh 16px 24px; + background: var(--black-alpha-48); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.shell-command-bg.show { display: flex; } +.shell-command { + width: min(640px, 100%); + max-height: min(680px, 76vh); + display: flex; + flex-direction: column; + background: var(--surface-raised); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + box-shadow: var(--shadow-floating); + overflow: hidden; +} +.shell-command-h { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-faint); +} +.shell-command-h .ic { + width: 30px; + height: 30px; + display: grid; + place-items: center; + background: var(--heat-12); + color: var(--heat); + border: 1px solid var(--heat-20); + border-radius: var(--r-md); + flex-shrink: 0; +} +.shell-command-h .ic svg { width: 14px; height: 14px; } +.shell-command-h input { + flex: 1; + min-width: 0; + height: 34px; + border: 0; + background: transparent; + color: var(--accent-black); + font-family: inherit; + font-size: 15px; + outline: none; +} +.shell-command-h input::placeholder { color: var(--black-alpha-48); } +.shell-command-close { + height: 26px; + padding: 0 8px; + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + background: var(--background-lighter); + color: var(--black-alpha-48); + font-family: var(--font-inter); + font-size: 11px; + font-weight: 700; + letter-spacing: .02em; + cursor: pointer; +} +.shell-command-close:hover { color: var(--accent-black); border-color: var(--black-alpha-24); } +.shell-command-list { + padding: 8px; + overflow-y: auto; +} +.shell-command-section { + padding: 8px 10px 6px; + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--black-alpha-48); + letter-spacing: .06em; +} +.shell-command-item { + width: 100%; + min-height: 48px; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px; + border: 0; + border-radius: var(--r-md); + background: transparent; + color: var(--accent-black); + font-family: inherit; + text-align: left; + cursor: pointer; +} +.shell-command-item:hover, +.shell-command-item.active { + background: var(--heat-12); + color: var(--heat); +} +.shell-command-item .cmd-ic { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + background: var(--surface); + color: currentColor; + flex-shrink: 0; +} +.shell-command-item .cmd-ic svg { width: 14px; height: 14px; } +.shell-command-item .cmd-main { + flex: 1; + min-width: 0; +} +.shell-command-item .cmd-title { + display: block; + font-size: 13px; + font-weight: 600; + color: currentColor; +} +.shell-command-item .cmd-sub { + display: block; + margin-top: 2px; + font-size: 12px; + line-height: 1.45; + color: var(--black-alpha-56); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.shell-command-item:hover .cmd-sub, +.shell-command-item.active .cmd-sub { color: var(--black-alpha-72); } +.shell-command-item .cmd-key { + min-width: 26px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 7px; + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + background: var(--surface); + color: var(--black-alpha-48); + font-family: var(--font-inter); + font-size: 10px; + font-weight: 700; + letter-spacing: .02em; +} +.shell-command-empty { + padding: 48px 20px; + display: grid; + place-items: center; + gap: 8px; + color: var(--black-alpha-48); + font-size: 13px; +} +.shell-command-foot { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-top: 1px solid var(--border-faint); + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .04em; +} +.shell-command-foot .spacer { flex: 1; } + +.shell-account-menu { + position: fixed; + z-index: 1700; + min-width: 232px; + display: none; + padding: 8px; + background: var(--surface-raised); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + box-shadow: var(--shadow-floating); +} +.shell-account-menu.show { display: block; } +.shell-account-head { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 8px 10px; + border-bottom: 1px solid var(--border-faint); + margin-bottom: 6px; +} +.shell-account-head .av { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--accent-black); + color: var(--accent-white); + font-size: 12px; + font-weight: 600; +} +.shell-account-head .nm { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--accent-black); +} +.shell-account-head .mail { + display: block; + margin-top: 2px; + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--black-alpha-48); + letter-spacing: .02em; +} +.shell-account-menu button { + width: 100%; + min-height: 34px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 9px; + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--accent-black); + font-family: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} +.shell-account-menu button svg { + width: 14px; + height: 14px; + color: var(--black-alpha-56); +} +.shell-account-menu button:hover { + background: var(--black-alpha-4); + color: var(--heat); +} +.shell-account-menu button:hover svg { color: var(--heat); } +.shell-account-menu .sep { + height: 1px; + background: var(--border-faint); + margin: 6px 4px; +} + +/* ─── Modal ─── */ +.modal-bg { + position: fixed; inset: 0; + background: rgba(21, 20, 15, .42); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: none; align-items: center; justify-content: center; + z-index: 999; + opacity: 0; + transition: opacity .2s; +} +.modal-bg.show { display: flex; opacity: 1; } +.modal { + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + max-width: 480px; width: 90%; + position: relative; + transform: scale(.96); + transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1); + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; +} +.modal-bg.show .modal { transform: scale(1); } +.modal::before, .modal::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; + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 13px; line-height: 1; +} +.modal::before { top: -7px; left: -7px; } +.modal::after { bottom: -7px; right: -7px; } +.modal .corner-tr, .modal .corner-bl { + 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; +} +.modal .corner-tr { top: -7px; right: -7px; } +.modal .corner-bl { bottom: -7px; left: -7px; } +.modal-h { + padding: 22px 24px 16px; + border-bottom: 1px solid var(--border-faint); + display: flex; align-items: center; gap: 14px; + flex-shrink: 0; +} +.modal-h .ic-m { + width: 36px; height: 36px; + background: var(--heat-12); + color: var(--heat); + border-radius: var(--r-md); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.modal-h .ic-m svg { width: 17px; height: 17px; } +.modal-h .ti { font-size: 16px; font-weight: 600; line-height: 1.4; color: var(--accent-black); } +.modal-h .ti span { + display: block; font-family: var(--font-mono); font-size: 11px; + color: var(--black-alpha-48); font-weight: 400; margin-top: 4px; letter-spacing: .04em; +} +.modal-b { + padding: 20px 24px; + font-size: 13.5px; + color: var(--black-alpha-72); + line-height: 1.75; + overflow-y: auto; + overscroll-behavior: contain; + flex: 1 1 auto; + min-height: 0; +} +.modal-b .mono-acc { + font-family: var(--font-mono); color: var(--heat); + background: var(--heat-12); padding: 2px 6px; + font-size: 11.5px; border-radius: var(--r-sm); +} +.modal-f { + padding: 16px 24px; + border-top: 1px solid var(--border-faint); + display: flex; justify-content: flex-end; gap: 10px; + flex-shrink: 0; +} + +/* ─── 统一卡片删除按钮 ──────────────────────────────────────── + 矩形 + 删除 icon, hover 卡片才显示 + 用法: 卡片内放 + 外层卡片需 position: relative + 触发: .product-card / .asset-card / .project-card / .task-card / .model-card hover + --------------------------------------------------------- */ +.card-del-btn { + position: absolute; + top: 8px; right: 8px; + width: 32px; height: 32px; + background: rgba(255,255,255,.95); + border: 1px solid var(--black-alpha-12); + border-radius: var(--r-md); + display: grid; place-items: center; + color: var(--black-alpha-56); + cursor: pointer; + opacity: 0; + transition: opacity .18s, color .18s, border-color .18s, background .18s; + box-shadow: 0 2px 6px rgba(0,0,0,.06); + z-index: 5; + padding: 0; + font-family: inherit; +} +.card-del-btn svg { width: 14px; height: 14px; } +.product-card:hover .card-del-btn, +.asset-card:hover .card-del-btn, +.project-card:hover .card-del-btn, +.task-card:hover .card-del-btn, +.model-card:hover .card-del-btn { + opacity: 1; +} +.card-del-btn:hover { + color: var(--accent-crimson); + border-color: var(--accent-crimson); + background: var(--surface); +} +.card-del-btn:active { transform: scale(.95); } + +/* ─── Drawer ─── */ +.drawer-bg { + position: fixed; inset: 0; + background: rgba(21, 20, 15, .32); + display: none; + z-index: 90; +} +.drawer-bg.show { display: block; } +.drawer { + position: fixed; right: 0; top: 0; bottom: 0; + width: 540px; max-width: 100vw; + background: var(--surface); + border-left: 1px solid var(--border-faint); + z-index: 95; + transform: translateX(100%); + transition: transform .25s cubic-bezier(.32, .72, 0, 1); + display: flex; flex-direction: column; + box-shadow: -4px 0 24px rgba(21, 20, 15, .04); +} +.drawer.show { transform: translateX(0); } +.drawer-h { + padding: 20px 24px; + border-bottom: 1px solid var(--border-faint); + display: flex; align-items: center; +} +.drawer-h h3 { font-size: 16px; font-weight: 600; color: var(--accent-black); } +.drawer-h .x { + margin-left: auto; width: 32px; height: 32px; + border-radius: var(--r-md); + display: grid; place-items: center; + color: var(--black-alpha-56); cursor: pointer; + transition: background var(--t-base); +} +.drawer-h .x:hover { background: var(--black-alpha-4); color: var(--accent-black); } +.drawer-b { padding: 24px; overflow-y: auto; flex: 1; overscroll-behavior: contain; } +.drawer-f { + padding: 16px 24px; + border-top: 1px solid var(--border-faint); + display: flex; gap: 10px; justify-content: flex-end; + background: var(--background-lighter); +} + +/* ─── Scrollbar · 全局隐藏可视滚动条,保留滚动能力 + 用 !important 覆盖各页 inline \n\n\n
\n\n
\n
\n

消费

\n
// 余额 · 充值 · 4 维消费视图 + 账单流水
\n
\n
\n\n\n
\n \n
\n \n
\n
团队余额
\n
¥327.40
\n
// 充值累加 · 不重置
\n
\n
\n
\n
本月限额
\n
¥3,000.00
\n
// 按自然月重置
\n
\n
\n
当月已用
\n
¥162.60
\n
// 占比 5.4% · 健康
\n
\n
\n
\n
\n
\n 团队月剩余 ¥2,837.40\n 使用率 5.4%\n
\n
\n
\n\n \n
\n
\n
\n

快速充值

\n
// 充值后立刻到账,可开发票 · 仅超管可操作
\n
\n
已选 ¥500
\n
\n
\n
¥100
无赠送
\n
推荐
¥500
+ ¥30 赠送
\n
¥1000
+ ¥80 赠送
\n
¥3000
+ ¥300 赠送
\n
\n
\n
自定义金额
\n \n
\n \n \n
\n
\n
\n
\n\n\n
\n \n \n \n \n
\n\n\n
\n
\n
\n
\n

消费趋势

\n // 近 14 天 · 单位 ¥\n \n \n \n \n
\n
\n
\n \n
\n
\n \n
\n
\n
\n
14 天合计¥0.00
\n
日均¥0.00
\n
峰值¥0.00
\n
\n
\n\n
\n

本月按阶段分布

\n
// PRD §5.3.5 扣费规则 · 仅确认后扣
\n
视频片段(Seedance)¥98.40
\n
\n
故事板(image-2)¥36.00
\n
\n
基础资产¥21.00
\n
\n
脚本 LLM¥7.20
\n
\n
合计¥162.60
\n
\n
\n\n
\n

扣费 + 四层额度预检规则

\n
// PRD §5.3.5 + §10.3 · 对接团队请以此页为准
\n
\n ① 失败不扣:模型超时 / 内容审核拦截 / 生成异常一律不扣费。
\n ② 用户重跑不扣首次:第一次重跑保留原扣费,第二次起按次结算。
\n ③ 仅在你点击 [ 确认通过 ] 时入账
\n ④ 导出不再扣费,所有 token 已在过程中结算。\n
\n
\n
// 任务确认前 · 四层额度预检(任一不通过即拦截)
\n
1个人日剩余 ≥ 任务预估 × 1.2
\n
2个人月剩余 ≥ 同上
\n
3团队月剩余 ≥ 同上
\n
4团队总余额 ≥ 同上
\n
\n
\n
\n\n\n
\n
\n \n \n \n \n 0 个项目 · 消耗 ¥0.00\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
项目商品所属成员当前阶段状态消耗
\n
\n // 当前筛选条件下没有项目 · 试试调整状态 / 时间范围\n
\n
\n\n\n
\n
\n \n \n \n \n 0 人 · 合计 ¥0.00\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n
成员角色已完成项目已用 / 月度额度最近活跃
\n
\n // 当前筛选条件下没有成员 · 试试调整角色 / 时间范围\n
\n
\n\n\n
\n
\n \n \n \n \n \n 0\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
时间项目 / 类型详情成员状态金额
\n
\n // 当前筛选条件下没有账单 · 试试调整阶段 / 成员 / 时间范围\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
扫码支付// 微信支付
\n
\n
\n
支付金额
\n
¥500.00
\n
// 含 ¥30 赠送 · 实到账 ¥530
\n
\n
微信扫码
/topup/wx/TX...
\n
\n
// 5 分钟内有效 · 到账后自动关闭
\n
\n
\n \n \n
\n
\n
\n\n
\n\n\n\n\n\n\n", + "assetFactory": "\n\n\n\n\n图片生成 · Airshelf\n\n\n\n\n\n
\n\n
\n
\n

图片生成

\n
\n // 一键生成\n ·\n 电商视觉素材,提升内容制作效率\n
\n
\n
\n\n\n
\n\n \n
\n \n \n\n
\n
\n [ MODEL · TRY-ON ]\n
模特上身图
\n
选择模特,AI 生成商品模特上身效果图
\n\n
    \n
  • \n \n \n \n 支持多模特选择\n
  • \n
  • \n \n \n \n 一次生成 4 张\n
  • \n
  • \n \n \n \n 支持多商品并行\n
  • \n
\n\n
\n \n 开始生成\n \n \n [ ≈ ¥0.30 / 张 ]\n
\n
\n\n
\n
Ava · 9:16
\n
\n
变体 01
\n
变体 02
\n
变体 03
\n
\n
\n
\n
\n\n \n
\n \n \n\n
\n
\n [ PLATFORM · KIT ]\n
平台套图
\n
选择平台模板,AI 生成电商平台套图
\n\n
    \n
  • \n \n \n \n 覆盖主流电商平台\n
  • \n
  • \n \n \n \n 一键生成 4 张套图\n
  • \n
  • \n \n \n \n 智能排版设计\n
  • \n
\n\n
\n \n 开始生成\n \n \n [ ≈ ¥0.50 / 张 ]\n
\n
\n\n
\n
套图 / TB
\n
套图 / DY
\n
套图 / XHS
\n
套图 / PDD
\n
\n
\n
\n\n \n
\n \n \n\n
\n
\n [ IMAGE · STUDIO ]\n
图片创作
\n
自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写
\n\n
    \n
  • \n \n \n \n 人物 · 商品 全支持\n
  • \n
  • \n \n \n \n 正面 / 侧面 / 背面 一次输出\n
  • \n
  • \n \n \n \n 多镜头一致性保证\n
  • \n
\n\n
\n \n 开始生成\n \n \n [ ≈ ¥0.40 / 组 ]\n
\n
\n\n
\n
正 / 侧 / 背 · 三视图
\n
\n
\n
\n\n
\n\n\n
\n

任务中心

\n // 0 个 · 0 生成中 · 0 已完成 · 0 失败\n
\n\n\n
\n
全部 0
\n
生成中 0
\n
已完成 0
\n
失败 0
\n
\n\n\n
\n
\n \n \n
\n
\n \n
\n
全部时间
\n
\n
今天
\n
1 小时内
\n
10 分钟内
\n
\n
\n
\n \n
\n
\n \n \n
\n \n \n
\n
\n\n
// 显示 0 / 0 个任务
\n\n\n
\n \n \n \n \n \n \n \n \n \n \n \n
任务进度状态创建于
\n
\n\n\n\n\n\n\n\n
\n\n\n
\n
\n \n \n
\n
\n \n
\n
确认删除任务// CONFIRM DELETE
\n
\n
即将删除任务记录。
\n
\n \n \n
\n
\n
\n\n\n\n\n\n\n\n\n", + "dashboard": "\n\n\n\n\n工作台 · Airshelf\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "imageOptimize": "\n\n\n\n\n图片创作 · Airshelf\n\n\n\n\n\n
\n
\n\n \n \n\n \n
\n\n
\n \n \n \n \n
\n \n
\n
\n
\n \n
\n
\n
\n \n
\n
\n
\n\n
\n
\n \n
\n
\n\n \n\n \n
\n
\n \n
\n
\n \n \n
\n\n \n \n\n \n
\n\n \n\n
\n 比例\n 1:1\n \n
\n
\n\n
\n 风格\n 默认\n \n
\n
\n\n
\n 张数\n 4\n \n
\n
\n\n 预估 ¥0.40 · 余额 ¥327.40\n \n
\n
\n
\n\n
\n
\n
\n\n\n\n\n\n\n\n\n\n\n\n\n", + "library": "\n\n\n\n\n资产库 · Airshelf\n\n\n\n\n\n
\n\n
\n
\n

资产库

\n
// 跨项目复用 · 0 人 · 0 景 · 0 商 · 0
\n
\n
\n \n \n
\n
\n\n
\n
人物 0
\n
场景 0
\n
商品图 0
\n
成片 0
\n
我的上传 0
\n
未分类 0
\n
\n\n
\n
\n \n \n
\n\n \n
\n \n
\n
\n
\n \n
\n
\n
\n \n
\n
\n\n \n
\n \n
\n
\n\n \n
\n \n
\n
\n\n \n
\n \n
\n
\n
\n \n
\n
\n\n \n
\n \n
\n
\n\n \n
\n \n
\n
\n\n \n \n\n \n
\n \n
\n
\n
\n\n
// 显示 0 / 0 个资产
\n\n\n
\n
\n \n \n
林夕 · 都市白领
\n
\n
林夕
\n
女 · 青年 · 都市白领 · 用过 4 次
\n
\n
\n
\n \n \n
阿楠 · 同事女
\n
\n
阿楠
\n
女 · 青年 · 都市白领 · 用过 2 次
\n
\n
\n
\n \n \n
小七 · 学生女
\n
\n
小七
\n
女 · 青年 · 学生 · 用过 3 次
\n
\n
\n
\n \n \n
阿杰 · 通勤男
\n
\n
阿杰
\n
男 · 青年 · 都市白领 · 用过 2 次
\n
\n
\n
\n \n \n
\n \n \n 缺三视图\n \n \n \n MISSING TRI-VIEW\n \n 手动上传的人物未生成 正 / 侧 / 背 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。\n 建议:前往 图片生成 先补齐三视图,再发起后续生成。\n \n \n 妈妈 · 居家\n
\n
\n
妈妈 · 王姐
\n
女 · 中年 · 居家 · 用过 1 次
\n
\n
\n
\n \n \n
阿强 · 健身男
\n
\n
阿强
\n
男 · 青年 · 健身 · 用过 2 次
\n
\n
\n
\n \n \n
小苏 · 文艺女
\n
\n
小苏
\n
女 · 青年 · 文艺 · 用过 1 次
\n
\n
\n
\n \n \n
闺蜜组合 · 双人
\n
\n
闺蜜组合
\n
女 · 青年 · 都市白领 · 用过 1 次
\n
\n
\n
\n \n \n
豆豆 · 幼儿
\n
\n
豆豆
\n
女 · 幼年 · 居家 · 用过 2 次
\n
\n
\n
\n \n \n
小宇 · 中学生
\n
\n
小宇
\n
男 · 少年 · 学生 · 用过 1 次
\n
\n
\n
\n \n \n
\n \n \n 缺三视图\n \n \n \n MISSING TRI-VIEW\n \n 手动上传的人物未生成 正 / 侧 / 背 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。\n 建议:前往 图片生成 先补齐三视图,再发起后续生成。\n \n \n 李爷爷 · 居家\n
\n
\n
李爷爷
\n
男 · 老年 · 居家 · 用过 1 次
\n
\n
\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n
\n \n
\n

没有匹配的资产

\n

// 试试切换 tab 或修改搜索词

\n
\n\n\n\n\n\n
\n
\n \n \n\n
\n
\n \n
\n
上传资产// 跨项目共享 · 不消耗 token
\n \n
\n\n
\n
\n \n \n
\n\n
\n \n \n
\n \n \n \n 点击或拖拽上传图片\n // JPG / PNG / WEBP · 单文件\n
\n \n
\n\n
\n \n \n
\n\n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n \n
\n \n \n
\n\n \n
\n \n \n
\n\n \n
\n \n \n
\n
\n \n \n
\n
\n\n
\n // 跨项目共享 · 不消耗 token\n \n \n
\n
\n
\n\n
\n\n\n
\n
\n \n \n
\n
\n \n
\n
确认删除资产// CONFIRM DELETE
\n
\n
即将删除该资产。
\n
\n \n \n
\n
\n
\n\n\n
\n 已选 0\n \n \n \n
\n \n
\n
\n \n
\n\n\n\n\n\n\n\n", + "login": "\n\n\n\n\n登录 · Airshelf\n\n\n\n\n\n← 返回工作台\n\n
\n \n\n \n \n\n \n
\n
\n

登录

\n // /auth/login\n
\n

使用团队邀请邮箱登录,接受邀请后自动加入对应团队。

\n\n
\n
\n \n
\n \n \n
\n
\n\n
\n \n
\n \n \n \n
\n
\n\n
\n \n 忘记密码?\n
\n\n \n\n
OR
\n\n
\n \n \n
\n\n
\n 还没账号? 注册团队 →\n
\n
\n
\n
\n\n\n\n\n\n", + "messages": "\n\n\n\n\n消息中心 · Airshelf\n\n\n\n\n\n
\n
\n
\n
\n

消息中心

\n
// INBOX 任务提醒 · 团队协作 · 计费与系统公告
\n
\n
\n \n \n
\n
\n\n
\n
\n
\n 收件箱\n // 0 条\n
\n
\n
\n \n \n
\n
\n
\n\n
\n
\n\n
\n // 消息保留 90 天 · 高风险任务会同时进入工作台队列\n 管理通知策略 →\n
\n
\n
\n\n\n\n\n\n\n\n", + "modelPhoto": "\n\n\n\n\n模特上身图 · Airshelf\n\n\n\n\n\n
\n\n
\n\n \n \n\n \n
\n\n \n
\n \n
\n // 商品空间\n 未选择 · 请在左侧商品空间选一个\n
\n \n
\n \n \n
\n
\n \n
\n \n \n \n \n
\n
\n
\n \n
\n \n
暂无批次,生成后可按模特筛选
\n
\n
\n
\n\n
\n\n \n
\n\n \n
\n
\n 1\n 选择模特\n 全部模特 →\n
\n
\n
\n
\n
Ava
\n
Ava
\n
亚洲·25岁·清新
\n
\n
\n
\n
Luna
\n
Luna
\n
亚洲·22岁·学生
\n
\n
\n
\n
Mia
\n
Mia
\n
混血·28岁·OL
\n
\n
\n
\n
Zoe
\n
Zoe
\n
亚洲·30岁·健身
\n
\n
\n
\n\n \n
\n
\n 2\n 生成设置\n
\n
\n
// 生成数量 (每模特)
\n
\n \n \n \n
\n
\n
\n
// 图片比例
\n
\n \n \n \n
\n
\n
\n\n \n
\n \n
// 采用即扣费并入对应商品 AI 素材 · 未采用不扣
\n
\n\n
\n\n \n
\n \n
\n
// EMPTY STATE
\n
还没有生成结果
\n
先选商品、选模特,点击 立即生成 后,效果图会出现在这里
\n
\n\n \n \n\n
\n \n
\n
\n
待生成 · 1:1
\n
待生成 · 1:1
\n
待生成 · 1:1
\n
待生成 · 1:1
\n
\n
\n
\n\n
\n // 采用即扣费并入对应商品的 AI 素材库 →;未采用的图不扣费、不保存\n
// 切换左侧商品空间 · 查看其他商品的批次记录\n
\n
\n\n
\n
\n\n
\n
\n\n\n
\n
\n
\n

商品库

\n // 共 7 个商品\n
\n \n
\n
\n
\n \n
\n
\n
\n \n \n
\n \n
\n
\n
\n \n
\n
\n
\n
\n
\n
// 已选 0 个商品
\n \n \n
\n
\n
\n\n\n
\n\n\n\n
\n
\n
\n

模特库

\n \n
\n
\n \n
\n
\n
\n 性别\n \n \n \n
\n
\n 年龄\n \n \n \n
\n
\n
\n
\n \n
\n
\n\n \n
\n
\n \n

添加模特

\n // 添加模特 · 工作台\n \n
\n
\n\n \n
\n
\n
\n
\n
\n \n
\n // AI · STUDIO\n

用 AI 生成一位新模特

\n

描述外形 + 风格 + 服装,AI 会同时生成立绘 + 正/侧/背三视图,加入模特库。

\n
\n \n \n \n \n
\n
\n
\n
\n\n
\n
\n \n
\n
\n \n \n
\n\n \n \n\n \n
\n
比例3:4
\n
风格默认
\n
张数4
\n 预估 ¥0.80 · 余额 ¥327.40\n \n
\n
\n
\n
\n\n \n \n\n
\n
\n
\n
\n
\n
\n\n\n
\n
\n
\n
\n \n
\n
\n 添加模特\n // 选择来源 · AI 生成或本地上传\n
\n \n
\n
\n \n \n
\n
\n
\n\n\n\n
\n
\n
\n

模特详情

\n / 人物 · 模特\n \n
\n
\n
\n \n
\n
\n
\n
\n
立绘 · 3:4
\n
\n \n
\n
\n
\n \n
\n
\n
\n \n 三视图\n 16:9\n \n
\n
\n
正 / 侧 / 背 · 三视图
\n
\n
\n
\n
\n \n 简介\n
\n

\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n \n
\n
\n
\n\n\n
\n
\n
\n \n \n \n

缺三视图 · 仍要保存吗?

\n // MISSING TRI-VIEW\n
\n
\n 该模特尚未生成 正 / 侧 / 背 三视图。直接进入后续图片/视频生成时,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。\n

\n 建议先点「去生成三视图」补齐(约 12s · ¥0.30);若现在不生成,后续也可以在资产详情页里随时补回。\n
\n
\n \n \n \n
\n
\n
\n\n\n
\n
\n
\n \n \n \n

退出工作台?

\n // UNSAVED\n
\n
\n 工作台已有内容,退出后不会保存。可继续编辑并点「加入模特库」来保留进度。\n
\n
\n \n \n \n
\n
\n
\n\n\n\n\n\n\n\n\n", + "modelPhotoDemoA": "\n\n\n\n\n方案 A · 商品空间 · 模特上身图 · Airshelf\n\n\n\n\n\n
\n\n
// DEMO · 方案 A · 商品 = 项目空间(Q1: A+B)。左栏仅商品空间:🔍 搜索 + 最近 6 条 + 全部商品兜底入口;历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。
\n\n
\n\n \n \n\n \n
\n\n \n
\n
\n
// 商品空间
\n

透真补水面膜

\n
\n
\n 本商品 6\n ·\n 累计 22 张图\n ·\n 最近 3 分钟前\n
\n
\n\n \n
\n\n \n
\n
\n\n
\n
选择模特(已锁定商品 · 透真补水面膜)
\n
\n
\n
Ava · 3:4
\n
Ava
\n
\n
\n
Zoe · 3:4
\n
Zoe
\n
\n
\n
Ben · 3:4
\n
Ben
\n
\n
\n
Lin · 3:4
\n
Lin
\n
\n
\n
Mia · 3:4
\n
Mia
\n
\n
\n +\n
\n
\n
\n\n
\n
生成张数
\n
\n \n \n \n \n
\n
\n\n
\n
画面比例
\n
\n \n \n \n \n
\n
\n\n
\n
补充提示词(选填)
\n \n
\n\n
\n\n
\n
\n 预估扣费 ≈ ¥1.20\n 余额 ¥327.40\n
\n \n
\n
\n\n \n
\n
\n 最近批次 · Ava × 4 张\n // 3 分钟前 · 已完成\n
\n\n \n
\n
\n
\n
\n
Ava × 4 张
\n
透真补水面膜 · 3:4 · 3 分钟前 · ¥1.20
\n
\n
\n \n \n \n
\n
\n
\n
Ava · #1
3:4
\n
Ava · #2
3:4
\n
Ava · #3
3:4
\n
Ava · #4
3:4
\n
\n
\n\n \n
\n
\n
\n
\n
Zoe × 4 张
\n
透真补水面膜 · 3:4 · 12 分钟前 · ¥1.20
\n
\n
\n \n \n \n
\n
\n
\n
Zoe · #1
3:4
\n
Zoe · #2
3:4
\n
Zoe · #3
3:4
\n
Zoe · #4
3:4
\n
\n
\n\n \n
\n
\n
\n
\n
Ben × 2 张
\n
透真补水面膜 · 3:4 · 刚刚 · 生成中
\n
\n
\n \n
\n
\n
\n
生成中…
\n
生成中…
\n
\n
\n\n
\n
\n\n
\n\n
\n\n
\n\n\n\n\n\n\n\n", + "modelPhotoDemoB": "\n\n\n\n\n方案 A · v2 · 模特上身图 · Airshelf\n\n\n\n\n\n
\n\n
// DEMO v2 · 方案 A · 商品空间(A+B) + 任务流主区。左栏只保留商品空间(搜索+最近6条+全部入口),任务列表搬到主区,筛选放主区顶部 toolbar,参数面板底部 fixed 化(类 image-optimize)。
\n\n
\n\n \n \n\n \n
\n\n \n
\n
// 商品空间 · 模特上身图
\n

透真补水面膜

\n
\n
\n 美妆个护·\n 本商品 6·\n 累计 22 张图·\n 最近 3 分钟前\n
\n
\n
\n \n \n \n \n
\n
\n
\n\n \n
\n\n
\n 今天\n 3 批 · 10 张\n
\n\n \n
\n
\n
\n
\n
Ava × 4 张 已完成
\n
\n 3:4·\n 3 分钟前·\n ¥1.20\n
\n
\n
\n \n \n \n
\n
\n
\n
Ava · #1
3:4
\n
Ava · #2
3:4
\n
Ava · #3
3:4
\n
Ava · #4
3:4
\n
\n
\n\n \n
\n
\n
\n
\n
Zoe × 4 张 已完成
\n
\n 3:4·\n 12 分钟前·\n ¥1.20\n
\n
\n
\n \n \n \n
\n
\n
\n
Zoe · #1
3:4
\n
Zoe · #2
3:4
\n
Zoe · #3
3:4
\n
Zoe · #4
3:4
\n
\n
\n\n \n
\n
\n
\n
\n
Ben × 2 张 生成中
\n
\n 3:4·\n 刚刚·\n ¥0.60\n
\n
\n
\n \n
\n
\n
\n
生成中…
\n
生成中…
\n
\n
\n\n \n
\n 昨天\n 2 批 · 8 张\n
\n\n
\n
\n
\n
\n
Lin × 4 张 已完成
\n
\n 3:4·\n 昨天 18:24·\n ¥1.20\n
\n
\n
\n \n \n \n
\n
\n
\n
Lin · #1
3:4
\n
Lin · #2
3:4
\n
Lin · #3
3:4
\n
Lin · #4
3:4
\n
\n
\n\n \n
\n 更早\n 1 批 · 2 张 · 含 1 失败\n
\n\n
\n
\n
\n
\n
Ava × 2 张 失败
\n
\n 3:4·\n 2 天前\n
\n
\n
\n \n \n
\n
\n
\n
失败 · 点重跑
\n
失败 · 点重跑
\n
\n
\n\n
\n\n \n
\n
\n \n \n \n \n \n 预估 ¥1.20 · 余额 ¥327.40\n \n
\n
\n\n
\n\n
\n\n
\n\n\n\n\n\n\n\n", + "pipeline": "\n\n\n\n\n流水线 · Airshelf\n\n\n\n\n\n
\n\n\n\n\n\n\n
\n
\n
\n
\n
\n 镜头脚本\n · 空 · 待生成\n
\n
\n 来源未选择\n 风格待确认\n 人物待确认\n
\n
\n
\n // 人物\n \n
\n
\n // 场景\n \n
\n
\n \n \n
\n
\n \n
\n
\n\n
\n\n
\n
\n
AI
\n 脚本助手\n · GPT-4o\n \n \n
\n
\n \n
\n
\n
\n \n \n
\n \n \n \n
\n
\n \n
\n
\n
\n\n
\n
[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]
\n
\n \n \n
\n
\n
\n\n\n
\n
\n
\n
商品3 张
\n
人物2/2
\n
场景3/3
\n
\n 基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。\n

\n // 人物 +¥0.20/张\n // 场景 +¥0.15/张\n 商品图无成本(直接复用商品库)\n
\n
\n\n
\n \n
\n
\n

商品 · 透真补水面膜

\n \n
\n
\n
\n
\n \n \n 缺三视图\n \n \n \n MISSING TRI-VIEW\n \n 该商品还未生成 正 / 侧 / 背 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。\n 建议:点右下 AI 生成三视图 先补齐三视图,再发起后续生成。\n \n \n 透真补水面膜 · 主图\n
\n
\n
透真补水面膜
\n
美妆个护
\n
2026-05-15 创建
\n
\n
\n \n
\n
\n
\n
// 三视图预览 · 生成中
\n
\n
\n
\n
// 历史版本 · 0
\n
\n
\n
\n
\n
\n\n \n
\n
\n

人物 · 2 个

\n \n \n
\n\n
\n
\n
林夕 · 都市白领
\n
\n
主角 · 林夕
\n
25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。
\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
\n 生成中 · 约 8s\n
\n
\n
\n
朋友/同事 · 阿楠
\n
25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。
\n
\n \n \n \n
\n
\n
\n
\n\n
\n\n \n
\n
\n

场景 · 3 个

\n \n \n
\n\n
\n
\n
深夜办公桌
\n
\n
深夜办公桌
\n
深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。
\n
\n \n \n \n
\n
\n
\n
\n
床头特写
\n
\n
卧室床头
\n
米白色床品,木质床头柜,闹钟显示晚间时间。
\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
!
\n 生成失败\n
\n
\n
\n
通勤地铁失败
\n
早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。
\n
\n \n \n \n
\n
\n
\n
\n\n
\n
\n
\n\n
\n
[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]
\n
\n \n \n
\n
\n
\n\n\n
\n
\n
\n
\n \n
\n
未选择
\n
\n\n
\n
\n
\n 故事板 · 场 1\n \n 已生成\n
\n
\n 整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。\n
\n
\n \n \n \n
\n 仅支持整张重跑 · 不能局部改某一镜。如需调单镜,先在 Stage 1 脚本 改镜头描述,再回此处整张重跑。\n
\n
\n\n
// 本场提示词
\n
\n\n
\n \n \n ~¥0.45/场\n
\n\n
\n
// 历史版本(0)
\n
\n
// 暂无历史版本
\n
\n
\n\n
\n\n
// 绑定的资产
\n
\n 林夕(人物)\n 深夜办公桌(场景)\n
\n
\n
\n
\n\n
\n
[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]
\n
\n \n \n
\n
\n
\n\n\n
\n
\n
\n
视频生成 · 3 / 3 完成
\n
// 每场 Seedance 约 15 秒 · 已完成所有场次
\n
\n
\n 100%\n \n \n \n
\n\n
\n
\n
\n 场 1 · 0-15s\n
\n
\n
\n
场 1 · 深夜办公桌完成
\n
15s · 1080×1920 · ¥0.45
\n
\n \n \n \n
\n
\n
\n
\n
\n 场 2 · 15-27s\n
\n
\n
\n
场 2 · 面膜包装/特写完成
\n
12s · 1080×1920 · ¥0.45
\n
\n \n \n \n
\n
\n
\n
\n
\n 场 3 · 27-40s\n
\n
\n
\n
场 3 · 化妆台/产品定格完成
\n
13s · 1080×1920 · ¥0.45
\n
\n \n \n \n
\n
\n
\n
\n\n
\n
[ 已完成 3 场 · 累计 ¥1.35 · 总时长 40s · 失败不扣 · 通过后扣 ]
\n
\n \n \n
\n
\n
\n\n\n
\n
\n
\n

视频详情

\n // 场 1 · 15s\n \n
\n
\n
\n
\n
大视频预览
\n
\n
\n
// 基础信息
\n
\n\n
\n
// 历史版本 · 3
\n
\n
\n\n
\n
\n // 视频提示词\n
\n
\n
\n
\n
\n
\n
\n \n
\n \n \n
\n
\n
\n
\n\n\n
\n
\n
\n
9:16 预览 · 1080×1920
\n
\n \n \n \n 00:00.00 / 00:15.00\n
\n
\n\n
\n
\n
字幕
\n
转场
\n
BGM
\n
\n
// 字幕样式
\n
\n
真实分享
朴素白底
\n
真实分享
影视黑底
\n
真实分享
手写描边
\n
真实分享
综艺暖黄
\n
\n\n
\n\n
// 当前选中(未选)
\n
起始
\n
时长
\n
音量
\n
速度
\n
入场交叉淡化
\n\n
\n\n
// BGM
\n
\n 温柔治愈钢琴 · 0:42\n \n
\n
\n\n
\n
\n \n \n \n \n \n \n \n
\n // zoom\n \n
\n
\n\n
\n
// time
\n
\n 0s\n \n 2s\n \n 4s\n \n 6s\n \n 8s\n \n 10s\n \n 12s\n \n 14s\n 15s\n
\n
\n\n
\n
\n \n 视频\n
\n
\n
1深夜办公桌
\n
2面膜包装
\n
3精华液微距
\n
4敷面膜平躺
\n
5化妆台
\n
6产品定格
\n
\n
\n\n
\n
\n \n 字幕\n
\n
\n
加班三天 脸已经不能看了…
\n
还好我有这个 透真玻尿酸面膜
\n
30g 精华 一片顶三片
\n
敷完起来脸是软的
\n
化妆都能看出来
\n
5 片 ¥39.9 囤起来
\n
\n
\n
\n\n
\n
\n \n BGM\n
\n
\n
\n \n 温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出)\n
\n
\n
\n
\n
\n\n
\n
[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]
\n
\n \n \n \n
\n
\n
\n\n\n
\n
\n
\n

资产详情

\n / kind\n \n
\n
\n
\n \n
\n
\n
立绘
\n \n
\n
\n
\n \n
\n \n
\n
\n \n 三视图\n 16:9\n \n
\n
\n
正 / 侧 / 背 · 三视图
\n
\n
\n \n 暂无三视图,建议用 AI 生成以保证多角度一致性\n \n
\n
\n \n
\n
\n \n 简介\n
\n

\n
\n
\n \n
\n
\n
\n
\n
\n
\n \n
\n \n
\n
\n
\n\n\n
\n
\n
\n

新增人物

\n // 立绘必填 + 三视图(可 AI 生成)\n \n
\n
\n
\n \n \n
\n
\n
\n
// 立绘*
\n
\n \n 点击上传立绘\n PNG / JPG · ≤10MB\n
\n
\n
\n
// 三视图(可选 · 16:9 单图)
\n
\n
正 / 侧 / 背 · 三视图
\n
\n
\n \n 没有三视图?上传立绘后用 AI 自动生成\n \n
\n
\n
\n
\n
\n \n \n
\n
\n
\n\n\n
\n
\n
\n

演员库

\n // 共 0 个\n \n
\n
\n \n
\n
\n \n
\n
\n
\n \n
\n
\n\n \n
\n
\n \n

添加演员

\n // 添加演员 · 工作台\n \n
\n
\n
\n
\n
\n
\n
\n \n
\n // AI · STUDIO\n

用 AI 生成一位新演员

\n

描述外形 + 风格 + 服装,AI 会同时生成立绘 + 正/侧/背三视图,加入演员库。

\n
\n
\n
\n
\n
\n
\n
\n
\n \n \n
\n \n
\n
比例3:4
\n
风格默认
\n
张数4
\n 预估 ¥0.80 · 余额 ¥327.40\n \n
\n
\n
\n
\n\n \n
\n
\n
\n
\n
\n
\n\n\n
\n
\n
\n \n \n \n

退出工作台?

\n // UNSAVED\n
\n
\n 工作台已有内容,退出后不会保存。可继续编辑并点「加入」来保留进度。\n
\n
\n \n \n \n
\n
\n
\n\n\n
\n
\n
\n
\n \n
\n
\n 添加\n // 选择来源 · AI 生成或本地上传\n
\n \n
\n
\n \n \n
\n
\n
\n\n\n\n
\n
\n \n
\n
\n \n
\n
额度预检通过// 4 层检查 · 全部通过
\n
\n
\n
\n
\n \n
\n
\n 任务已拦截 · 余额或额度不足以覆盖本次预估。请联系超管充值,或将团队月限额调高。\n
\n
\n
\n \n \n 前往充值\n
\n
\n
\n\n
\n\n\n\n\n\n\n", + "platformCover": "\n\n\n\n\n平台套图 · Airshelf\n\n\n\n\n\n
\n\n
\n\n \n \n\n \n
\n\n \n
\n \n
\n // 商品空间\n 未选择 · 请在左侧商品空间选一个\n
\n \n
\n \n \n
\n
\n \n
\n \n \n \n \n
\n
\n
\n \n
\n \n
暂无批次,生成后可按平台筛选
\n
\n
\n
\n\n
\n\n \n
\n\n \n
\n
\n 1\n 选择平台\n
\n
\n
\n
\n \n
抖音电商
\n
\n
\n
\n \n
淘宝
\n
\n
\n
\n \n
天猫
\n
\n
\n
\n \n
京东
\n
\n
\n
\n \n
拼多多
\n
\n
\n
\n \n
小红书
\n
\n
\n
\n \n
快手
\n
\n
\n
\n \n
视频号
\n
\n
\n
\n \n
亚马逊
\n
\n
\n
\n \n
1688
\n
\n
\n
\n\n \n
\n
\n 2\n 生成设置\n
\n
\n
// 生成数量
\n
\n \n \n \n
\n
\n
\n\n \n
\n \n
// 满意后点 [入资产库] 才扣费 · 失败不扣
\n
\n\n
\n\n \n
\n \n
\n
// EMPTY STATE
\n
还没有生成结果
\n
先选商品、选平台,点击 立即生成 后,效果图会出现在这里
\n
\n\n
\n \n
4 张
\n
商品未选择
\n
平台未选择
\n
\n
\n \n
\n\n
\n // 采用即扣费并入对应商品的 AI 素材库 →;未采用的图不扣费、不保存\n
// 切换左侧商品空间 · 查看其他商品的批次记录\n
\n
\n\n
\n
\n\n
\n
\n\n\n
\n\n\n\n
\n
\n
\n

商品库

\n // 共 7 个商品\n
\n \n
\n
\n
\n \n
\n
\n
\n \n \n
\n \n
\n
\n
\n
\n
\n
\n
\n
// 已选 0 个商品
\n \n \n
\n
\n
\n\n\n\n\n\n\n\n\n\n", + "productCreate": "\n\n\n\n\n新建商品 · Airshelf\n\n\n\n\n\n\n\n\n
\n
正在打开「新建商品」…
\n
如未自动跳转,点击这里
\n
\n\n\n\n\n", + "productCreateUpload": "\n\n\n\n\n新建商品 · Airshelf\n\n\n\n\n\n", + "productDetail": "\n\n\n\n\n\n\n\n商品详情 · Airshelf\n\n\n\n\n\n
\n\n \n
\n

补水保湿精华液

\n
\n\n \n
\n\n
\n
\n 商品信息\n \n
\n \n
\n \n
// 三视图预览 · 待生成
\n
// 尚未生成 · 点击下方按钮开始
\n
\n \n \n ~¥0.30 / 次\n
\n
\n
// 历史版本 · 0
\n
\n
\n
\n
\n \n \n \n
\n \n \n \n
\n
\n\n
\n\n
\n
\n
商品名称
\n
\n 补水保湿精华液\n \n
\n
\n
\n
品类
\n
\n 美妆个护 / 精华液\n \n
\n
\n
\n
目标人群
\n
\n 22-32 岁女性、敏感肌、办公室通勤\n \n
\n
\n
\n
核心卖点
\n
\n
\n 透明质酸 + B5,敷完不黏不闷\n 30g 大容量精华液\n 0 香精 0 酒精,敏感肌可用\n
\n
    \n \n
\n
\n
\n
\n\n
\n
\n 商品图片\n (6)\n
\n
\n
1:1
\n
1:1
\n
1:1
\n
1:1
\n
1:1
\n
1:1
\n
\n \n
\n
\n
\n\n
\n
\n\n
\n
快速操作
\n
\n
// 图片生成
\n
\n
\n \n 模特上身图\n
\n
\n \n 平台套图\n
\n
\n \n 图片创作\n
\n
\n
\n
\n
// 视频生成
\n
\n
\n \n 生成视频\n
\n
\n
\n
\n\n
\n\n \n
\n \n \n \n
\n\n \n
\n\n
\n
全部 AI 素材 (32)
\n \n \n
\n
\n \n \n
\n \n
\n
\n\n
\n
模特上身图3:4
通过2026-05-19 15:30
\n
模特上身图3:4
通过2026-05-19 15:30
\n
模特上身图3:4
不通过2026-05-19 15:30
\n
模特上身图3:4
通过2026-05-19 15:30
\n
模特上身图3:4
归档2026-05-19 15:30
\n
平台套图3:4
通过2026-05-19 15:30
\n
平台套图3:4
通过2026-05-19 15:30
\n
平台套图3:4
不通过2026-05-19 15:30
\n
平台套图3:4
归档2026-05-19 15:30
\n
平台套图3:4
通过2026-05-19 15:30
\n
三视图3:4
通过2026-05-19 15:30
\n
三视图3:4
归档2026-05-19 15:30
\n
\n\n
\n
\n\n \n
\n
\n
该商品视频项目 (4)
\n
\n \n
\n
\n\n
\n
视频 · 9:16补水面膜 · v3
已完成2026-05-20 12:08
\n
视频 · 9:16补水面膜 · v2
视频生成 4/62026-05-19 10:24
\n
视频 · 9:16熬夜急救 · v1
已归档2026-05-18 21:42
\n
视频 · 9:16补水面膜 · v1
故事板失败2026-05-17 16:00
\n
\n\n
\n
\n\n \n
\n\n \n
\n
\n
// TOTAL
\n
12 个任务
\n
\n
\n
// SUCCESS
\n
9
\n
\n
\n
// RUNNING
\n
2
\n
\n
\n
// FAILED
\n
1
\n
\n
\n\n \n
\n
任务记录 (12)
\n \n \n \n
\n \n
\n
\n\n \n
\n
\n \n 任务 / 编号\n 数量\n 状态\n 提交时间\n 完成时间\n 耗时\n 操作\n
\n\n
\n
\n
视频素材 // T-2026-0519-0007
\n
1 个
\n
\n 生成中 60%\n \n
\n
2026-05-19 16:00
\n
\n
\n
\n \n \n
\n
\n\n
\n
\n
模特上身图 // T-2026-0519-0006
\n
3 张
\n
\n 排队中\n
\n
2026-05-19 15:58
\n
\n
\n
\n \n \n
\n
\n\n
\n
\n
模特上身图 // T-2026-0519-0005
\n
5 张
\n
\n 已完成\n
\n
2026-05-19 15:30
\n
2026-05-19 15:32
\n
2m 14s
\n
\n \n
\n
\n\n
\n
\n
平台套图 // T-2026-0519-0004
\n
4 张
\n
\n 已完成\n
\n
2026-05-19 14:20
\n
2026-05-19 14:23
\n
3m 02s
\n
\n \n
\n
\n\n
\n
\n
模特上身图 // T-2026-0519-0003
\n
4 张
\n
\n 已完成\n
\n
2026-05-19 13:10
\n
2026-05-19 13:13
\n
2m 50s
\n
\n \n
\n
\n\n
\n
\n
三视图 // T-2026-0519-0002
\n
3 张
\n
\n 失败\n
\n
2026-05-19 12:00
\n
2026-05-19 12:01
\n
30s
\n
\n \n \n
\n
\n\n
\n
\n
平台套图 // T-2026-0518-0001
\n
6 张
\n
\n 已完成\n
\n
2026-05-18 18:42
\n
2026-05-18 18:46
\n
4m 10s
\n
\n \n
\n
\n\n
\n\n
\n
\n\n
\n\n\n
\n
\n \n
\n // 三视图(正/侧/背) · v1\n \n
\n
\n
\n // 生成于 --:--\n \n Esc 关闭\n
\n
\n
\n\n\n\n\n\n\n\n", + "products": "\n\n\n\n\n商品库 · Airshelf\n\n\n\n\n\n
\n\n
\n
\n

商品库

\n
// 0 SKU · 商品信息会作为脚本和资产生成的素材
\n
\n
\n \n \n
\n
\n\n\n
\n\n \n
\n
\n \n \n
\n
\n \n
\n
\n
\n \n
\n
\n \n
\n\n
\n // 显示 7 / 7 个商品\n
\n \n \n
\n
\n\n \n
\n
\n
\n \n \n
补水面膜 · 1200×800
\n
\n
透真玻尿酸补水面膜
\n
美妆个护
\n
2026-05-15 创建
\n
\n
\n \n \n 素材 124\n \n ·\n \n \n 视频 36\n \n \n
\n
\n\n
\n \n \n
蓝牙耳机 · 1200×800
\n
\n
南卡 Lite Pro 蓝牙耳机
\n
数码 3C
\n
2026-05-12 创建
\n
\n
\n \n \n 素材 96\n \n ·\n \n \n 视频 28\n \n \n
\n
\n\n
\n \n \n
速食牛肉面 · 1200×800
\n
\n
滋啦速食牛肉面 · 6 桶装
\n
食品饮料
\n
2026-05-10 创建
\n
\n
\n \n \n 素材 96\n \n ·\n \n \n 视频 24\n \n \n
\n
\n\n
\n \n \n
防晒霜 · 1200×800
\n
\n
透真清透物理防晒霜
\n
美妆个护
\n
2026-05-08 创建
\n
\n
\n \n \n 素材 76\n \n ·\n \n \n 视频 18\n \n \n
\n
\n\n
\n \n \n
咖啡冻干粉 · 1200×800
\n
\n
三顿半同款冻干咖啡粉
\n
食品饮料
\n
2026-05-05 创建
\n
\n
\n \n \n 素材 68\n \n ·\n \n \n 视频 21\n \n \n
\n
\n\n
\n \n \n
空气炸锅 · 1200×800
\n
\n
小熊 4L 可视空气炸锅
\n
家居家电
\n
2026-05-03 创建
\n
\n
\n \n \n 素材 54\n \n ·\n \n \n 视频 16\n \n \n
\n
\n\n
\n \n \n
瑜伽裤 · 1200×800
\n
\n
露露同款裸感瑜伽裤
\n
运动户外
\n
2026-04-30 创建
\n
\n
\n \n \n 素材 42\n \n ·\n \n \n 视频 12\n \n \n
\n
\n
\n\n
\n
\n \n
\n

没有匹配的商品

\n

// 试试切换分类或修改搜索词

\n
\n
\n\n \n \n\n
\n\n
\n\n\n
\n\n\n\n
\n
\n \n \n
\n
\n \n
\n
// PICK ONE
\n
\n
\n
\n \n \n
\n
\n
\n \n
\n
\n
\n\n\n
\n
\n \n \n
\n
\n \n
\n
确认删除商品// CONFIRM DELETE
\n
\n
即将删除 ,此操作无法撤销,商品下生成的素材记录也将一并清理。
\n
\n \n \n
\n
\n
\n\n\n
\n 已选 0\n \n \n \n \n
\n\n\n\n\n\n\n\n", + "projectWizard": "\n\n\n\n\n新建项目 · Airshelf\n\n\n\n\n\n
\n\n
\n
\n

新建项目

\n
// 商品 → 配置 · 2 步开始生成
\n
\n
\n 退出\n
\n
\n\n
\n \n
\n \n
\n\n
\n\n\n
\n
\n
\n

商品库

\n \n
\n \n
\n
\n
\n \n
\n
\n
\n \n \n
\n \n
\n
\n
\n
\n
\n
\n
\n
// 单选:点击商品即选用,自动关闭
\n \n
\n
\n
\n\n\n\n\n\n\n\n\n\n\n", + "projects": "\n\n\n\n\n视频项目 · Airshelf\n\n\n\n\n\n
\n\n
\n
\n

视频项目

\n
// 0 个 · 0 进行中 · 0 完成 · 0 失败
\n
\n
\n \n \n \n 新建项目\n \n
\n
\n\n
\n
全部 0
\n
进行中 0
\n
已完成 0
\n
失败 0
\n
\n\n
\n
\n \n \n
\n
\n \n
\n
\n
\n \n
\n
\n
\n \n
\n
\n \n \n
\n \n \n
\n
\n\n
// 显示 12 / 12 个项目
\n\n\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
项目商品脚本来源进度状态更新于
\n
\n
9:16
\n
补水面膜 · 痛点种草 · v3
6 镜 · 0-15s
\n
\n
透真补水面膜AI 全生\n
\n
\n 3/5\n
\n
故事板生成中12 分钟前\n
\n \n
\n
\n
\n
\n
9:16
\n
速食牛肉面 · 加班治愈
4 镜 · 0-12s
\n
\n
滋啦速食 · 6 桶装一句话主题\n
\n
\n 2/5\n
\n
资产生成中37 分钟前
\n
\n
9:16
\n
透真防晒 · 通勤对比
6 镜 · 0-18s
\n
\n
透真清透防晒霜AI 全生\n
\n
\n 4/5\n
\n
视频生成 4/62 小时前
\n
\n
9:16
\n
咖啡冻干 · 早八剧情
5 镜 · 0-15s
\n
\n
三顿半同款冻干一句话主题\n
\n
\n 3/5\n
\n
故事板生成失败昨天 18:42
\n
\n
9:16
\n
蓝牙耳机 · 开箱测评
5 镜 · 0-15s
\n
\n
南卡 Lite Pro自带脚本\n
\n
\n 5/5\n
\n
已完成5 月 7 日
\n
\n
9:16
\n
瑜伽裤 · 通勤穿搭
5 镜 · 0-15s
\n
\n
露露同款瑜伽裤AI 全生\n
\n
\n 5/5\n
\n
已完成5 月 6 日
\n
\n
9:16
\n
空气炸锅 · 小户型
4 镜 · 0-12s
\n
\n
小熊 4L 空气炸锅一句话主题\n
\n
\n 5/5\n
\n
已完成5 月 4 日
\n
\n\n\n
\n
\n
\n \n \n
9:16 · 镜 3/6
\n
\n
\n
补水面膜 · 痛点种草 · v3
\n
透真补水面膜 · 6 镜
\n
\n
\n
\n 3/5\n
\n
\n 故事板生成中\n 12 分钟前\n
\n
\n
\n\n
\n \n \n
9:16 · 镜 2/4
\n
\n
\n
速食牛肉面 · 加班治愈
\n
滋啦速食 · 4 镜
\n
\n
\n
\n 2/5\n
\n
\n 资产生成中\n 37 分钟前\n
\n
\n
\n\n
\n \n \n
9:16 · 镜 4/6
\n
\n
\n
透真防晒 · 通勤对比
\n
透真清透防晒霜 · 6 镜
\n
\n
\n
\n 4/5\n
\n
\n 视频 4/6\n 2 小时前\n
\n
\n
\n\n
\n \n \n
9:16 · 镜 3/5
\n
\n
\n
咖啡冻干 · 早八剧情
\n
三顿半同款 · 5 镜
\n
\n
\n
\n 3/5\n
\n
\n 故事板失败\n 昨天 18:42\n
\n
\n
\n\n
\n \n \n
9:16 · 5/5 ✓
\n
\n
\n
蓝牙耳机 · 开箱测评
\n
南卡 Lite Pro · 5 镜
\n
\n
\n
\n 5/5\n
\n
\n 已完成\n 5 月 7 日\n
\n
\n
\n\n
\n \n \n
9:16 · 5/5 ✓
\n
\n
\n
瑜伽裤 · 通勤穿搭
\n
露露同款 · 5 镜
\n
\n
\n
\n 5/5\n
\n
\n 已完成\n 5 月 6 日\n
\n
\n
\n\n
\n \n \n
9:16 · 5/5 ✓
\n
\n
\n
空气炸锅 · 小户型
\n
小熊 4L · 4 镜
\n
\n
\n
\n 5/5\n
\n
\n 已完成\n 5 月 4 日\n
\n
\n
\n\n
\n
\n\n\n
\n
\n \n
\n

没有匹配的项目

\n

// 试试切换 tab 或修改搜索词

\n
\n\n\n
\n
\n \n \n
\n
\n \n
\n
确认删除项目// CONFIRM DELETE
\n
\n
即将删除项目。
\n
\n \n \n
\n
\n
\n\n\n
\n 已选 0\n \n \n \n \n
\n\n
\n\n\n\n\n\n\n", + "register": "\n\n\n\n\n注册团队 · Airshelf\n\n\n\n\n\n← 返回登录\n\n
\n \n\n \n\n
\n
\n

注册团队

\n // /auth/register\n
\n

填写团队信息开通账户,默认成为团队超管。

\n\n
\n
\n \n
\n \n \n
\n
\n\n
\n \n
\n \n \n
\n
\n\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n\n
\n \n
\n \n \n
\n
\n\n \n\n \n\n
\n 已有账号? 登录 →\n
\n
\n
\n
\n\n\n\n\n\n", + "settings": "\n\n\n\n\n设置 · Airshelf\n\n\n\n\n\n
\n\n
\n
\n

设置

\n
// 个人信息 · 偏好 · 通知 · 安全
\n
\n
\n \n \n
\n
\n\n
\n \n \n\n \n
\n \n
\n

个人信息

\n
// 头像、姓名、联系方式 · 邮箱用于接收通知
\n\n
\n
头像
\n
\n
\n
\n
\n \n \n
\n
\n
\n
\n
\n
显示名称*
\n
\n
\n
\n
登录邮箱
\n
\n \n \n
\n
\n
\n
手机号
\n
\n \n \n
\n
\n
\n
所属团队
// 一人一团队
\n
\n 小李的店\n 超管 · 创建者\n 管理团队 →\n
\n
\n
\n
用户 ID
// 不可改
\n
USR-2026-A8F2-001
\n
\n
\n\n \n
\n

安全

\n
// 登录密码、双因素、在用设备
\n\n
\n
登录密码
\n
\n ●●●●●●●●●●\n 上次修改 2026-04-12\n \n
\n
\n
\n
两步验证
// 推荐开启
\n
\n \n 短信 + Authenticator\n
\n
\n\n

在用设备

\n
// 不在此列表上的设备登录会触发短信告警
\n
\n
\n
\n
\n
MacBook Pro · ChromeCURRENT
\n
// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42
\n
\n
\n 当前会话\n
\n
\n
\n
\n
iPhone 15 · Safari
\n
// 上海 · 2026-05-20 21:43
\n
\n
\n \n
\n
\n
\n
\n
Windows · Edge
\n
// 杭州 · 2026-05-18 09:12
\n
\n
\n \n
\n
\n
\n \n
\n
\n\n \n
\n

通知

\n
// 邮件、短信、站内提示开关
\n\n
\n
项目完成通知
// 视频导出后
\n
\n \n 站内 · 邮件 · 短信\n
\n
\n
\n
任务失败告警
\n
\n \n 站内 · 邮件\n
\n
\n
\n
额度不足提醒
// 团队或个人剩余 < 20%
\n
\n \n 站内 · 短信\n
\n
\n
\n
异地登录告警
\n
\n \n 短信\n
\n
\n
\n\n \n
\n

创作默认

\n
// 新建项目时的预填值,可在向导中改
\n\n
\n
默认模板
\n
\n
\n
痛点种草
// 30s 默认档
\n
开箱测评
// 45s 默认档
\n
对比展示
// 45s 默认档
\n
教程演示
// 60s 默认档
\n
剧情带货
// 60s 默认档
\n
\n
\n
\n
\n
默认时长档
\n
\n
\n 30s\n 45s\n 60s\n
\n // 60s = 4 段 × 15s\n
\n
\n
\n
默认字幕样式
\n
\n
\n
大字综艺
// 抖音热门
\n
简洁电商
// 信息清晰
\n
高级排版
// 居中衬线
\n
弹幕轻量
// 滚动出现
\n
强调爆款
// 高对比
\n
\n
\n
\n
\n
默认 BGM 库
\n
\n \n
\n
\n
\n
默认转场
\n
\n \n
\n
\n
\n
导出水印
// VIP 可关闭
\n
\n \n 右下角 · Airshelf\n 升级 VIP →\n
\n
\n
\n\n \n
\n

显示

\n
// 界面外观与语言
\n\n
\n
外观
\n
\n \n
\n
\n
\n
语言
\n
\n \n
\n
\n
\n
表格密度
\n
\n \n
\n
\n
\n\n
\n // Airshelf · v2.1 · build 20260521\n
\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
退出当前账号// LOG OUT CURRENT SESSION
\n
\n
\n

确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。

\n
\n
项目、资产、团队成员与余额数据都会保留
\n
仅影响当前浏览器会话,不会下线其他设备
\n
\n \n
\n
\n \n \n
\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
上传头像// 用于个人主页、评论与团队展示
\n
\n
\n\n
\n
\n
\n
当前头像 · 默认
\n
// 系统生成 · 取姓氏首字
\n
\n
\n\n
\n \n \n \n
点击选择 · 或拖入图片
\n JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256\n \n
\n\n
\n
最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形
\n
不要上传含他人肖像的图片,违规可能导致账号封停
\n
\n\n
\n
\n \n \n
\n
\n
\n\n
\n\n\n\n\n\n\n", + "team": "\n\n\n\n\n团队 · Airshelf\n\n\n\n\n\n
\n\n
\n
\n

团队管理

\n
// 成员 · 角色 · 额度 · 共享资产库
\n
\n
\n \n
\n
\n\n\n
\n\n\n
\n \n\n
\n
\n
[ TEAM ]
\n
小李的店 企业
\n
// 团队 ID: T-2026-A8F2 · 创建于 2026-04-12 · 5 名成员
\n
\n
\n \n \n
\n
\n\n
\n\n
\n
\n
[ 充值余额 ]
\n
¥327.40
\n
// 团队总池
\n
\n
\n
[ 月限额 ]
\n
¥3,000
\n
// 自然月重置
\n
\n
\n
[ 当月已用 ]
\n
¥162.60
\n
// 占月限 5.4%
\n
\n
\n
[ 当月剩余 ]
\n
¥2,837.40
\n
// 还可生成约 280 个项目
\n
\n
\n
\n\n\n
\n
\n

团队动态

\n // 最近 12 条\n 全部 →\n
\n
\n
\n
\n
\n
张运营完成视频补水面膜 · v3
\n
10 分钟前
\n
\n
\n
\n
\n
\n
王小姐上传到资产库林夕 · 主播图
\n
28 分钟前
\n
\n
\n
\n
\n
\n
小李邀请新成员林新人
\n
2 小时前
\n
\n
\n
\n
\n
\n
陈策划创建项目蓝牙耳机 · 开箱测评
\n
4 小时前
\n
\n
\n
\n
\n
\n
张运营采用故事板补水面膜 · 场 3 · v2
\n
昨天 18:32
\n
\n
\n
\n
\n
\n
小李团队充值+¥500.00
\n
昨天 11:02
\n
\n
\n
\n
\n
\n
王小姐删除资产透真防晒 · 旧版主图
\n
2 天前
\n
\n
\n
\n
\n\n
\n\n
\n \n
\n
\n
\n

成员列表 // 5 人 · 1 超管 / 1 团管 / 3 成员

\n \n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
成员角色每日额度月度额度当月已用操作
\n
\n
\n\n \n
\n
\n

角色权限

\n
// PRD §10.2 权限矩阵节选
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
能力超管团管成员
邀请 / 移除成员
设置成员额度
团队充值
设置月限额
编辑别人项目
团队共享资产库管理仅自传
查看团队消费明细仅自己
创建项目 / 用 AI 流程
\n
\n\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
设置月限额// 自然月重置 · 仅超管可改
\n
\n
\n
\n \n \n
\n
\n \n
\n \n \n \n \n \n
\n
\n
\n
当月已用¥162.60
\n
本次调整后剩余¥2,837.40
\n
\n
\n
\n \n \n
\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
重置登录密码 // 该成员当前会话会被强制下线
\n
\n
\n
\n \n 旧密码即刻失效,该成员需用新密码重新登录\n
\n
\n \n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
团队动态// 共 12 条
\n \n
\n
\n \n \n \n \n \n // 共 12 条\n
\n
\n
\n
\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
创建账户// 直接生成账号 · 分享给成员登录
\n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n
\n
成员
\n
// 创建项目 + 用资产
\n
\n
\n
团管
\n
// 管成员 + 改额度
\n
\n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
账户已创建// 把下方凭据分享给成员 · 凭据仅展示一次
\n
\n
\n
\n
\n 登录地址\n https://airshelf.com/login\n \n
\n
\n 用户名\n \n \n
\n
\n 初始密码\n \n \n
\n
\n
\n
建议成员首次登录后立即修改密码
\n
凭据请通过加密渠道(企微 / 飞书私聊)分享,不要发到公共群
\n
\n
\n
\n \n \n
\n
\n
\n\n\n
\n
\n \n
\n
\n \n
\n
编辑成员
\n
\n
\n
\n \n \n
\n
\n \n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n
\n\n\n\n\n\n\n" +}; diff --git a/core/frontend/src/routes/exact-pages/account.tsx b/core/frontend/src/routes/exact-pages/account.tsx new file mode 100644 index 0000000..af91049 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/account.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactAccountPage(props: Omit) { + return ; +} + +export default ExactAccountPage; diff --git a/core/frontend/src/routes/exact-pages/asset-factory.tsx b/core/frontend/src/routes/exact-pages/asset-factory.tsx new file mode 100644 index 0000000..d9f25d4 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/asset-factory.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactAssetFactoryPage(props: Omit) { + return ; +} + +export default ExactAssetFactoryPage; diff --git a/core/frontend/src/routes/exact-pages/dashboard.tsx b/core/frontend/src/routes/exact-pages/dashboard.tsx new file mode 100644 index 0000000..69b1bae --- /dev/null +++ b/core/frontend/src/routes/exact-pages/dashboard.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactDashboardPage(props: Omit) { + return ; +} + +export default ExactDashboardPage; diff --git a/core/frontend/src/routes/exact-pages/image-optimize.tsx b/core/frontend/src/routes/exact-pages/image-optimize.tsx new file mode 100644 index 0000000..2a716a3 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/image-optimize.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactImageOptimizePage(props: Omit) { + return ; +} + +export default ExactImageOptimizePage; diff --git a/core/frontend/src/routes/exact-pages/library.tsx b/core/frontend/src/routes/exact-pages/library.tsx new file mode 100644 index 0000000..8cdc506 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/library.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactLibraryPage(props: Omit) { + return ; +} + +export default ExactLibraryPage; diff --git a/core/frontend/src/routes/exact-pages/login.tsx b/core/frontend/src/routes/exact-pages/login.tsx new file mode 100644 index 0000000..8b4d845 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/login.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactLoginPage(props: Omit) { + return ; +} + +export default ExactLoginPage; diff --git a/core/frontend/src/routes/exact-pages/messages.tsx b/core/frontend/src/routes/exact-pages/messages.tsx new file mode 100644 index 0000000..16f3303 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/messages.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactMessagesPage(props: Omit) { + return ; +} + +export default ExactMessagesPage; diff --git a/core/frontend/src/routes/exact-pages/model-photo-demo-a.tsx b/core/frontend/src/routes/exact-pages/model-photo-demo-a.tsx new file mode 100644 index 0000000..d1e4165 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/model-photo-demo-a.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactModelPhotoDemoAPage(props: Omit) { + return ; +} + +export default ExactModelPhotoDemoAPage; diff --git a/core/frontend/src/routes/exact-pages/model-photo-demo-b.tsx b/core/frontend/src/routes/exact-pages/model-photo-demo-b.tsx new file mode 100644 index 0000000..210210e --- /dev/null +++ b/core/frontend/src/routes/exact-pages/model-photo-demo-b.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactModelPhotoDemoBPage(props: Omit) { + return ; +} + +export default ExactModelPhotoDemoBPage; diff --git a/core/frontend/src/routes/exact-pages/model-photo.tsx b/core/frontend/src/routes/exact-pages/model-photo.tsx new file mode 100644 index 0000000..a5271d7 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/model-photo.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactModelPhotoPage(props: Omit) { + return ; +} + +export default ExactModelPhotoPage; diff --git a/core/frontend/src/routes/exact-pages/pipeline.tsx b/core/frontend/src/routes/exact-pages/pipeline.tsx new file mode 100644 index 0000000..596f95a --- /dev/null +++ b/core/frontend/src/routes/exact-pages/pipeline.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactPipelinePage(props: Omit) { + return ; +} + +export default ExactPipelinePage; diff --git a/core/frontend/src/routes/exact-pages/platform-cover.tsx b/core/frontend/src/routes/exact-pages/platform-cover.tsx new file mode 100644 index 0000000..80023a6 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/platform-cover.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactPlatformCoverPage(props: Omit) { + return ; +} + +export default ExactPlatformCoverPage; diff --git a/core/frontend/src/routes/exact-pages/product-create-upload.tsx b/core/frontend/src/routes/exact-pages/product-create-upload.tsx new file mode 100644 index 0000000..671dd4d --- /dev/null +++ b/core/frontend/src/routes/exact-pages/product-create-upload.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactProductCreateUploadPage(props: Omit) { + return ; +} + +export default ExactProductCreateUploadPage; diff --git a/core/frontend/src/routes/exact-pages/product-create.tsx b/core/frontend/src/routes/exact-pages/product-create.tsx new file mode 100644 index 0000000..29ecc83 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/product-create.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactProductCreatePage(props: Omit) { + return ; +} + +export default ExactProductCreatePage; diff --git a/core/frontend/src/routes/exact-pages/product-detail.tsx b/core/frontend/src/routes/exact-pages/product-detail.tsx new file mode 100644 index 0000000..bdeb109 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/product-detail.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactProductDetailPage(props: Omit) { + return ; +} + +export default ExactProductDetailPage; diff --git a/core/frontend/src/routes/exact-pages/products.tsx b/core/frontend/src/routes/exact-pages/products.tsx new file mode 100644 index 0000000..29eafaa --- /dev/null +++ b/core/frontend/src/routes/exact-pages/products.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactProductsPage(props: Omit) { + return ; +} + +export default ExactProductsPage; diff --git a/core/frontend/src/routes/exact-pages/project-wizard.tsx b/core/frontend/src/routes/exact-pages/project-wizard.tsx new file mode 100644 index 0000000..6392934 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/project-wizard.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactProjectWizardPage(props: Omit) { + return ; +} + +export default ExactProjectWizardPage; diff --git a/core/frontend/src/routes/exact-pages/projects.tsx b/core/frontend/src/routes/exact-pages/projects.tsx new file mode 100644 index 0000000..d4bc9e1 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/projects.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactProjectsPage(props: Omit) { + return ; +} + +export default ExactProjectsPage; diff --git a/core/frontend/src/routes/exact-pages/register.tsx b/core/frontend/src/routes/exact-pages/register.tsx new file mode 100644 index 0000000..eb905c7 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/register.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactRegisterPage(props: Omit) { + return ; +} + +export default ExactRegisterPage; diff --git a/core/frontend/src/routes/exact-pages/settings.tsx b/core/frontend/src/routes/exact-pages/settings.tsx new file mode 100644 index 0000000..b5b2602 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/settings.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactSettingsPage(props: Omit) { + return ; +} + +export default ExactSettingsPage; diff --git a/core/frontend/src/routes/exact-pages/team.tsx b/core/frontend/src/routes/exact-pages/team.tsx new file mode 100644 index 0000000..22448e2 --- /dev/null +++ b/core/frontend/src/routes/exact-pages/team.tsx @@ -0,0 +1,9 @@ +/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */ +import { ExactDocumentPage } from "../exact-document"; +import type { ExactDocumentPageProps } from "../exact-document"; + +export function ExactTeamPage(props: Omit) { + return ; +} + +export default ExactTeamPage; diff --git a/core/frontend/src/routes/index.ts b/core/frontend/src/routes/index.ts new file mode 100644 index 0000000..ec6031c --- /dev/null +++ b/core/frontend/src/routes/index.ts @@ -0,0 +1,14 @@ + + +export { AuthScreen } from "./auth-screen"; +export { ExactDashboardApp } from "./exact-dashboard"; +export { Dashboard } from "./dashboard"; +export { ProductsPage, ProductCreateUploadPage, ProductDetailPage } from "./products"; +export { ProjectWizardPage, ProjectsPage } from "./projects"; +export { PipelinePage } from "./pipeline"; +export { LibraryPage } from "./library"; +export { AccountPage } from "./account"; +export { TeamPage } from "./team"; +export { MessagesPage } from "./messages"; +export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools"; +export { SettingsPage } from "./settings"; diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx new file mode 100644 index 0000000..f456b47 --- /dev/null +++ b/core/frontend/src/routes/library.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Search, Upload } from "lucide-react"; +import type { Asset } from "../types"; +import { Drawer } from "../components/overlays"; + +export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (formData: FormData) => Promise | void }) { + const [drawer, setDrawer] = useState(false); + const [file, setFile] = useState(null); + const [name, setName] = useState(""); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!file) return; + const formData = new FormData(); + formData.append("file", file); + formData.append("name", name || file.name); + formData.append("asset_type", "image"); + formData.append("category", "upload"); + await onUpload(formData); + setDrawer(false); + } + + return ( + <> +

资产库

// 跨项目复用 · {assets.length} assets
+
+
+
{assets.map((asset) =>
{asset.asset_type}
{asset.name}
{asset.category} · {asset.source}
)}
+ setDrawer(false)}>
setFile(event.target.files?.[0] || null)} />
setName(event.target.value)} />
+ + ); +} diff --git a/core/frontend/src/routes/messages.tsx b/core/frontend/src/routes/messages.tsx new file mode 100644 index 0000000..3895e4e --- /dev/null +++ b/core/frontend/src/routes/messages.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; +import { Bell, Search } from "lucide-react"; +import type { Page } from "./route-config"; +import { routeLabels } from "./route-config"; + +export function MessagesPage({ navigate }: { navigate: (page: Page) => void }) { + const messages = [ + { id: "m1", type: "task", priority: "ok", title: "补水面膜 · 痛点种草 v3 成片已完成", brief: "7 镜 · 40 秒 · ¥18.40 已结算。", body: "视频生成全部完成。", target: "pipeline" as Page }, + { id: "m2", type: "billing", priority: "warn", title: "团队余额低于预警线", brief: "当前余额低于 ¥100。", body: "建议先充值或降低任务量。", target: "account" as Page } + ]; + const [query, setQuery] = useState(""); + const [selectedId, setSelectedId] = useState(messages[0].id); + const visible = messages.filter((item) => !query || `${item.title} ${item.brief}`.toLowerCase().includes(query.toLowerCase())); + const selected = messages.find((item) => item.id === selectedId) || visible[0] || messages[0]; + return ( + <> +

消息中心

// {messages.length} 条总计 任务提醒 · 团队协作 · 计费与系统公告
+
收件箱// 显示 {visible.length} 条
setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
{visible.map((item) => )}

{selected.title}

{selected.type}

{selected.body}

关联资源{routeLabels[selected.target]}
+ + ); +} diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx new file mode 100644 index 0000000..bb1f783 --- /dev/null +++ b/core/frontend/src/routes/pipeline.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { Copy, Play } from "lucide-react"; +import type { Project } from "../types"; +import { FragmentStageStep } from "../components/pipeline-stage"; +import { stageOrder, statusPill } from "./stage-config"; + +export function PipelinePage({ + project, + loading, + onRefresh, + onGenerateScript, + onAdoptScript, + onGenerateBaseAsset, + onGenerateStoryboard, + onSkipStoryboard, + onSubmitVideo, + onPollVideo, + onSubmitAllVideos, + onPollAllVideos, + onSubmitExport +}: { + project: Project; + loading: boolean; + onRefresh: () => void; + onGenerateScript: (prompt: string) => void; + onAdoptScript: (scriptId: string) => void; + onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void; + onGenerateStoryboard: (prompt: string) => void; + onSkipStoryboard: () => void; + onSubmitVideo: (segmentId: string, prompt: string) => void; + onPollVideo: (segmentId: string) => void; + onSubmitAllVideos: (prompt: string) => void; + onPollAllVideos: () => void; + onSubmitExport: () => void; +}) { + const [activeStage, setActiveStage] = useState(project.current_stage || "script"); + const [prompt, setPrompt] = useState("突出商品卖点,节奏紧凑,适合短视频投放"); + const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图"); + const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感"); + const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version)); + + return ( + <> +
9:16

{project.name}

{project.status}
// AI 全生 · 4 段 · 60s · 9:16
+
++{stageOrder.map((stage, index) => { const projectIndex = stageOrder.indexOf(project.current_stage as never); return setActiveStage(stage)} />; })}
+ {activeStage === "script" &&
AI
脚本助手· Doubao