完善二期清单
@@ -29,7 +29,7 @@
|
|||||||
| 1.14 | 故事板进页面自动生成 | 不再手动点「开始生成」;**防刷新/返回/切页重复创建** | 上 | 明确 | **暂缓,需先补后端防重复**。这条听起来是「删掉一个手动按钮」,但那个按钮正是目前唯一的防重复措施——后端收到一次请求就新建一次任务、预扣一次积分,本身不检查「这段是不是已经在跑了」。改成进页面自动触发后,用户每刷新一次页面就重新触发一次。前置工作 = 给后端补「已经在跑就不重复建」的判断,对用户不可见,但省掉它这功能不能上线 |
|
| 1.14 | 故事板进页面自动生成 | 不再手动点「开始生成」;**防刷新/返回/切页重复创建** | 上 | 明确 | **暂缓,需先补后端防重复**。这条听起来是「删掉一个手动按钮」,但那个按钮正是目前唯一的防重复措施——后端收到一次请求就新建一次任务、预扣一次积分,本身不检查「这段是不是已经在跑了」。改成进页面自动触发后,用户每刷新一次页面就重新触发一次。前置工作 = 给后端补「已经在跑就不重复建」的判断,对用户不可见,但省掉它这功能不能上线 |
|
||||||
| 1.15 | 视频进页面自动生成 | 同 1.14;防重复创建视频任务 | 上 | 明确 | **暂缓,风险高于 1.14**。视频是全流程最贵的一步,而提交侧后端**完全没有**防重复:点两次就是两个任务、扣两份积分。现在只有前端按钮禁用挡着,绕开它(刷新、开两个标签页、点太快)就会重扣。**不补后端就上线 = 用户刷新一次重扣一批**。补法与 1.14 同源,视频优先做 |
|
| 1.15 | 视频进页面自动生成 | 同 1.14;防重复创建视频任务 | 上 | 明确 | **暂缓,风险高于 1.14**。视频是全流程最贵的一步,而提交侧后端**完全没有**防重复:点两次就是两个任务、扣两份积分。现在只有前端按钮禁用挡着,绕开它(刷新、开两个标签页、点太快)就会重扣。**不补后端就上线 = 用户刷新一次重扣一批**。补法与 1.14 同源,视频优先做 |
|
||||||
| 1.16 | 全流程积分消耗展示 | 生成前预估 + 结束后明细。⚠️依赖各环节**计费标准先定清** | 上 | 待确认(PM:计费标准) | **部分完成,余下待 PM**。余额、每步单价、单任务预估、实际扣除、消费流水都已经有了;缺的只是流程级汇总(进条前总预估 + 出片后一次性明细)。⚠️这个汇总**不能让前端把各步单价加起来自己算**:视频按实际时长和用量动态计价,还有重跑次数、团队折扣两步取整,前端算的数跟最后真扣的对不上,用户会觉得平台乱扣钱。得先定清计费口径,再由后端出数 |
|
| 1.16 | 全流程积分消耗展示 | 生成前预估 + 结束后明细。⚠️依赖各环节**计费标准先定清** | 上 | 待确认(PM:计费标准) | **部分完成,余下待 PM**。余额、每步单价、单任务预估、实际扣除、消费流水都已经有了;缺的只是流程级汇总(进条前总预估 + 出片后一次性明细)。⚠️这个汇总**不能让前端把各步单价加起来自己算**:视频按实际时长和用量动态计价,还有重跑次数、团队折扣两步取整,前端算的数跟最后真扣的对不上,用户会觉得平台乱扣钱。得先定清计费口径,再由后端出数 |
|
||||||
| 1.17 | 保存模板 | 见模块 5(同一功能) | 下 | — | 第7段(见模块 5) |
|
| 1.17 | 保存模板 | 见模块 5(同一功能) | 下 | — | **已完成**(第7段,见模块 5) |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -55,11 +55,11 @@
|
|||||||
|
|
||||||
| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 |
|
| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 |
|
||||||
| --- | ------------- | ----------------------------------- | --- | --------------- | ----------------------------------------------------------------------------- |
|
| --- | ------------- | ----------------------------------- | --- | --------------- | ----------------------------------------------------------------------------- |
|
||||||
| 3.1 | 商品库新增「类目」字段 | 电商/本地生活;驱动后续挂 skill 与节点屏蔽。⚠️涉及商品库结构 | 下 | 待确认(PM/开发:范围排期) | 第8段。⚠️**不能复用现有「品类」字段**:商品库已有的品类是美妆/食品那种细分,「电商 / 本地生活」是另一个维度,必须新开字段,混用会打乱商品库筛选 |
|
| 3.1 | 商品库新增「类目」字段 | 电商/本地生活;驱动后续挂 skill 与节点屏蔽。⚠️涉及商品库结构 | 下 | **已完成(第8段)** | **已完成**。新开 `Product.business_type`(`ecommerce` / `local_life`),**没有复用现有「品类」字段**。存量商品默认电商。新建抽屉、详情编辑、商品库筛选都单独有「类目」这一维;选本地生活后品类下拉换成餐饮/丽人/酒店那一套 |
|
||||||
| 3.2 | 团购 = 虚拟商品建模 | 无实物商品,沿用主流程 SOP | 下 | 明确 | 第8段 |
|
| 3.2 | 团购 = 虚拟商品建模 | 无实物商品,沿用主流程 SOP | 下 | **已完成(第8段)** | **已完成**。本地生活允许不传主图也能建(团购没有实物可拍),卖点仍必填。建完照常走「生成视频」进主流程:脚本 → 基础资产 → 故事板 → 出片。脚本提示词会带上「业务类型:本地生活(团购/到店核销,无实物)」,模型按虚拟商品写,不按货架 SKU 写 |
|
||||||
| 3.3 | 探店脚本结构 + 镜头语言 | 达人→门店→点餐→试吃→环境→到店核销;探店镜头语言 | 下 | 待确认(内容原料待补) | 第8段。与 2.1/2.2 同类:是**内容活不是代码活**,得先有人写 |
|
| 3.3 | 探店脚本结构 + 镜头语言 | 达人→门店→点餐→试吃→环境→到店核销;探店镜头语言 | 下 | 跳过(等原料) | **跳过**。跟 2.1/2.2 同类:**内容活不是代码活**。没有探店案例和镜头语言稿,硬写一套会把脚本质量钉死在瞎编上。3.2 已经能用现有 SOP 出片;等客户补原料再单独立一段挂 skill |
|
||||||
| 3.4 | 本地生活节点屏蔽 | 三视图可跳过、模特上身图入口隐藏(按类目标签自动) | 下 | 明确 | 第8段(依赖 3.1 的新字段) |
|
| 3.4 | 本地生活节点屏蔽 | 三视图可跳过、模特上身图入口隐藏(按类目标签自动) | 下 | **已完成(第8段)** | **已完成**。按 `business_type=local_life` 自动:商品库卡不标「缺三视图」;详情页藏「AI 生成三视图」和「模特上身图」;流水线基础资产页三视图按钮换成「无需商品三视图,可直接进入下一步」;模特上身图工作台侧栏也不列出本地生活商品。**不挡进入下一步** |
|
||||||
| 3.5 | 是否覆盖图片线(平台套图) | 覆盖则平台清单换(美团/点评/抖音本地生活…) | 下 | 待确认(需求方:范围) | 第8段 |
|
| 3.5 | 是否覆盖图片线(平台套图) | 覆盖则平台清单换(美团/点评/抖音本地生活…) | 下 | 跳过(等 PM) | **跳过**。平台套图入口还在(本地生活也能用现有电商平台清单出图)。要换成美团/点评/抖音本地生活封面规范,得先有各平台尺寸清单和是否覆盖图片线的明确答复,否则会做错一版再推翻 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -86,10 +86,10 @@
|
|||||||
|
|
||||||
| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 |
|
| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 |
|
||||||
| --- | -------------- | --------------------------------------------- | --- | -------------- | ------------------------------------------------------------------ |
|
| --- | -------------- | --------------------------------------------- | --- | -------------- | ------------------------------------------------------------------ |
|
||||||
| 5.1 | 保存模板 | 存带货套路/脚本结构/人物场景风格/镜头节奏/转化方式 | 下 | 明确 | 第7段。**必须等第2、3段定稿**:模板存的就是「表现形式 × 视频结构 × 时长」那套参数,参数还没定型就先做模板,改一次废一次 |
|
| 5.1 | 保存模板 | 存带货套路/脚本结构/人物场景风格/镜头节奏/转化方式 | 下 | **已完成(第7段)** | 脚本页底栏加「存为模板」。存的是**套路不是文案**:带货套路(表现形式 × 视频结构)、脚本结构(每镜作用序列 钩子→痛点→卖点→CTA)、镜头节奏(镜数 + 每镜秒数)、人物设定、商品露出方式、结尾转化写法。**旧商品的口播文案一律不进模板**,弹窗里把「会存进去的内容」逐条摊开给用户看,并写明文案不存 |
|
||||||
| 5.2 | 换商品重跑 pipeline | 换商品后重做商品理解、卖点适配、资产生成、故事板生成(=满足「视频商品替换」需求,路径②) | 下 | 明确 | 第7段 |
|
| 5.2 | 换商品重跑 pipeline | 换商品后重做商品理解、卖点适配、资产生成、故事板生成(=满足「视频商品替换」需求,路径②) | 下 | **已完成(第7段)** | 新建项目第 2 步多一个「套路模板(可选)」下拉。选中后**后端按库里的模板回填**表现形式/结构/时长/人物到 `metadata.wizard`(前端只传 template_id,不留两份真相),脚本页设定卡自动带好,生成时把镜序节奏并进提示词。因为是**开新项目**,商品理解、卖点适配、基础资产、故事板全都按新商品重跑一遍,没有走「复制旧项目」的捷径 |
|
||||||
| 5.3 | 视频替换预期管理 | 产出为「同套路重出新视频」,非「仅换商品的原片」,需告知用户 | 下 | 待确认(客户:替换确切含义) | 第7段。是文案/沟通活,代码量很小,但**要在客户签字前说清楚**,否则验收时对不上 |
|
| 5.3 | 视频替换预期管理 | 产出为「同套路重出新视频」,非「仅换商品的原片」,需告知用户 | 下 | **已完成(第7段)** | 两处说明,都在用户点下去之前:① 新建项目选中模板时展开模板镜序 + 一句「文案、画面、模特与故事板都会按新商品重新生成 —— 不是把上一条片子换个商品名」;② 脚本页设定卡顶部再提示一次。**客户口径仍需签字前当面对齐**(见文末第 2 条) |
|
||||||
| 5.4 | 模板私有 vs 平台公共库 | 公共库价值大但复杂 | 下 | 待确认(PM) | 第7段。建议先只做私有模板,公共库另立一期 |
|
| 5.4 | 模板私有 vs 平台公共库 | 公共库价值大但复杂 | 下 | 跳过(等 PM) | **本期只做团队私有模板**,按建议执行。跳过理由:公共库要额外解决审核(谁能上架)、跨团队权限(模板里带的人物/场景设定是不是商业机密)、下架与版本治理三件事,都是产品先定规则、代码才好写的活。私有模板的数据结构已经预留 `team` 维度,将来加公共库=加一个「可见范围」字段,不用推翻重做 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,8 +113,8 @@
|
|||||||
|
|
||||||
## 待确认汇总(按对象,便于对齐)
|
## 待确认汇总(按对象,便于对齐)
|
||||||
|
|
||||||
**需求方 / 客户**:1.6/1.7 选项是否可增删 · ~~1.11 是否要迁移到新商品~~(**已按「要迁移」实现**:拆完的分镜稿会自动按当前项目的商品重写,参考视频里的商品/品牌/人物一律不保留;若客户其实想要「原样还原参考片」,回来改一句提示词即可)· 1.11 是否要接语音转写拿原片口播(**当前没接**,理由见第5段) · 3.5 是否覆盖图片线 · 5.3 替换确切含义 · 3.3 本地生活案例原料
|
**需求方 / 客户**:1.6/1.7 选项是否可增删 · ~~1.11 是否要迁移到新商品~~(**已按「要迁移」实现**:拆完的分镜稿会自动按当前项目的商品重写,参考视频里的商品/品牌/人物一律不保留;若客户其实想要「原样还原参考片」,回来改一句提示词即可)· 1.11 是否要接语音转写拿原片口播(**当前没接**,理由见第5段) · ~~3.5 是否覆盖图片线~~(**本期不覆盖**:本地生活仍用现有电商平台套图;要换美团/点评/抖音本地生活封面规范,需先给平台清单) · 5.3 替换确切含义 · 3.3 本地生活案例原料
|
||||||
**PM**:2.1/2.2 内容谁沉淀(★最高)· 1.16 计费标准 · 3.1 商品库改动排期 · 5.4 模板公共化 · 全局分期
|
**PM**:2.1/2.2 内容谁沉淀(★最高)· 1.16 计费标准 · ~~3.1 商品库改动排期~~(**已做**:新开 `business_type` 字段,不碰现有品类筛选)· ~~5.4 模板公共化~~(**本期按建议只做团队私有模板**;公共库要先定审核、跨团队权限、下架治理三条规则,规则定了再另立一期,数据结构已预留)· 全局分期
|
||||||
**开发**:1.9 时长现成方案可移植/单镜约束 ~~(已验证可移植,见 1.9 进度栏)~~ · 1.13 三视图重新生成入口 ~~(已确认不受影响)~~ · 4.x 自由创作从0做引用工作量
|
**开发**:1.9 时长现成方案可移植/单镜约束 ~~(已验证可移植,见 1.9 进度栏)~~ · 1.13 三视图重新生成入口 ~~(已确认不受影响)~~ · 4.x 自由创作从0做引用工作量
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -132,20 +132,20 @@
|
|||||||
| ~~**第4段**~~ | ~~自动生成 + 积分汇总:1.14 · 1.15 · 1.16~~ | **暂缓**。1.14/1.15 的前置(后端防重复)没做完之前不开工,否则用户刷新一次重扣一批;1.16 等 PM 定计费口径 |
|
| ~~**第4段**~~ | ~~自动生成 + 积分汇总:1.14 · 1.15 · 1.16~~ | **暂缓**。1.14/1.15 的前置(后端防重复)没做完之前不开工,否则用户刷新一次重扣一批;1.16 等 PM 定计费口径 |
|
||||||
| **第5段** | 上传视频提炼:1.11 · 补 1.4 的第三个入口 | **已完成**。原判「唯一一条新基建」偏保守:读图能力和 ffmpeg 都是现成的,实际只新写了抽帧。**两件要跟需求方说清的**:① **没接语音转写** —— 拆解只看画面,口播词只能从画面上的字幕读;带货参考片绝大多数带硬字幕,且口播本来就要按新商品重写,所以先不接。真要原片口播,需另开火山语音识别服务(新凭证 + 新计价,约 1 段工作量)。② **拆解稿是给人改的,不是直接出片** —— 稿子落在输入框里,用户逐镜校对完再点确定;AI 自己会在稿尾列「拆解存疑」提示该重点核对哪几条 |
|
| **第5段** | 上传视频提炼:1.11 · 补 1.4 的第三个入口 | **已完成**。原判「唯一一条新基建」偏保守:读图能力和 ffmpeg 都是现成的,实际只新写了抽帧。**两件要跟需求方说清的**:① **没接语音转写** —— 拆解只看画面,口播词只能从画面上的字幕读;带货参考片绝大多数带硬字幕,且口播本来就要按新商品重写,所以先不接。真要原片口播,需另开火山语音识别服务(新凭证 + 新计价,约 1 段工作量)。② **拆解稿是给人改的,不是直接出片** —— 稿子落在输入框里,用户逐镜校对完再点确定;AI 自己会在稿尾列「拆解存疑」提示该重点核对哪几条 |
|
||||||
| **第6段** | 自由创作接三库:模块 4 | 独立支线,不碰主流程 → 提前做掉。**已完成**(4.4 数据迁移经判断不需要做,理由见该行) |
|
| **第6段** | 自由创作接三库:模块 4 | 独立支线,不碰主流程 → 提前做掉。**已完成**(4.4 数据迁移经判断不需要做,理由见该行) |
|
||||||
| **第7段** | 保存模板 + 换商品重跑:模块 5(含 1.17) | 等第2、3段参数定稿 |
|
| **第7段** | 保存模板 + 换商品重跑:模块 5(含 1.17) | **已完成**。5.1/5.2/5.3 做掉,5.4 公共库按建议跳过(只做团队私有)。做法比预想轻:模板存的是「怎么讲」的一页大纲(镜数 / 每镜作用 / 秒数 / 商品露出 / 转化写法),换商品=**照常新建项目**,只是把这页大纲喂给脚本助手,后面商品理解、卖点、资产、故事板全部按新商品正常跑。**一条要跟需求方说清的**:模板里**不存旧商品的口播文案**(存了就会把上一个品牌漏进新片),所以「同套路重出」拿到的是结构一致、文案全新的一条片子 |
|
||||||
| **第8段** | 本地生活:模块 3 | 依赖主流程先稳,放最后 |
|
| **第8段** | 本地生活:模块 3 | **已完成能做的部分**。3.1 新开类目字段、3.2 团购无实物可建、3.4 按类目自动屏蔽三视图/上身图。**3.3 探店镜头语言跳过**(没案例原料)、**3.5 平台套图换清单跳过**(等需求方是否覆盖图片线) |
|
||||||
|
|
||||||
|
|
||||||
主链:**第1 → 2 → 6 → 5 → 7**(第1、2、5、6 段已完成;第3段已并入第2段;第4段暂缓,等后端防重复补完 + PM 定计费口径再回来);第8段放最后。
|
主链:**第1 → 2 → 6 → 5 → 7 → 8 已全部完成能做的部分**(第3段并入第2段;第4段暂缓,等后端防重复补完 + PM 定计费口径再回来)。剩下的都是卡原料/口径的:2.1/2.2 内容过一遍、3.3 探店案例、3.5 是否覆盖图片线、1.14/1.15/1.16 自动生成。
|
||||||
|
|
||||||
**至此模块1(脚本)除 1.14/1.15/1.16 三条暂缓项外全部做完**,三个脚本入口都通了。下一站是第7段(保存模板 + 换商品重跑,模块5)。
|
**模块 1(脚本)除 1.14/1.15/1.16 三条暂缓项外全部做完**;**模块 3(本地生活)工程侧做完**(3.3/3.5 等原料);**模块 4(自由创作)、模块 5(模板 / 换商品重跑)已完成**。
|
||||||
|
|
||||||
### ⚠️ 需要跟 PM / 客户提前说清楚的四条(听起来简单、实际有雷)
|
### ⚠️ 需要跟 PM / 客户提前说清楚的四条(听起来简单、实际有雷)
|
||||||
|
|
||||||
0. **1.11「画面描述七要素」的具体七项,需求文档里没给**。开发按「下游重拍时最少需要哪些信息」定了这七项,**请需求方比对确认**:① 主体(谁/什么在画面里)② 动作 ③ 场景/环境 ④ 景别(特写/中景/全景)⑤ 运镜(固定/推拉摇移/跟拍)⑥ 光线氛围 ⑦ 商品露出方式(手持/特写/使用中/未出现)。这七项是照着平台脚本已有的字段挑的,**改一项要连着改脚本结构**,所以趁早对齐;纯加一项文字描述则很便宜。
|
0. **1.11「画面描述七要素」的具体七项,需求文档里没给**。开发按「下游重拍时最少需要哪些信息」定了这七项,**请需求方比对确认**:① 主体(谁/什么在画面里)② 动作 ③ 场景/环境 ④ 景别(特写/中景/全景)⑤ 运镜(固定/推拉摇移/跟拍)⑥ 光线氛围 ⑦ 商品露出方式(手持/特写/使用中/未出现)。这七项是照着平台脚本已有的字段挑的,**改一项要连着改脚本结构**,所以趁早对齐;纯加一项文字描述则很便宜。
|
||||||
1. **1.14 / 1.15 自动生成** —— 见上,删按钮 = 拆掉唯一的防重复措施,会重复扣积分。**已暂缓**。
|
1. **1.14 / 1.15 自动生成** —— 见上,删按钮 = 拆掉唯一的防重复措施,会重复扣积分。**已暂缓**。
|
||||||
2. **5.3 视频替换** —— 客户很可能理解成「原片不动,只把里面的商品换掉」,平台实际能做的是「沿用同一套路,重新生成一条新视频」。这两件事在客户脑子里是一回事,在验收现场是两回事。**代码量很小,说不清楚的代价是整批返工**,务必在签字前对齐。
|
2. **5.3 视频替换** —— 客户很可能理解成「原片不动,只把里面的商品换掉」,平台实际能做的是「沿用同一套路,重新生成一条新视频」。这两件事在客户脑子里是一回事,在验收现场是两回事。**代码量很小,说不清楚的代价是整批返工**,务必在签字前对齐。**产品侧已按「同套路重出」实现并在界面上写明**(选模板时、生成前各提示一次),但**口径本身仍要当面跟客户确认**:如果客户坚持要「原片不动只换商品」,那是另一条技术路线(逐帧抠像 + 商品贴合),不在本期范围内,需单独评估。
|
||||||
3. **3.1 商品库「类目」字段** —— 要加的「电商 / 本地生活」跟商品库已有的「品类」(美妆/食品那种细分)是两个维度,**必须新开字段**。图省事塞进同一个字段,现有商品库筛选会全乱。
|
3. **3.1 商品库「类目」字段** —— **已按建议新开字段**(`business_type` = 电商 / 本地生活),没有塞进现有「品类」。筛选、新建、编辑、节点屏蔽都认这一维。存量商品默认电商,老数据不用迁。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""第7段抓图用的本地 sqlite 种子:一个团队 + 两个商品 + 一个带脚本的项目。
|
||||||
|
只在 DB_ENGINE=sqlite 的本地库跑,不碰共享开发库。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import django
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||||||
|
os.environ.setdefault("DB_ENGINE", "sqlite")
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from apps.accounts.models import Team, TeamMember, User # noqa: E402
|
||||||
|
from apps.billing.models import CreditAccount # noqa: E402
|
||||||
|
from apps.products.models import Product # noqa: E402
|
||||||
|
from apps.projects.models import Project, ProjectStage, ScriptSegment, ScriptVersion # noqa: E402
|
||||||
|
from apps.projects.services.pipeline import STAGE_ORDER # noqa: E402
|
||||||
|
|
||||||
|
user, created = User.objects.get_or_create(username="seg7", defaults={"is_active": True})
|
||||||
|
user.set_password("Restraint2026")
|
||||||
|
user.save()
|
||||||
|
team, _ = Team.objects.get_or_create(name="Seg7 Team", defaults={"owner": user})
|
||||||
|
TeamMember.objects.get_or_create(team=team, user=user, defaults={"role": TeamMember.Role.OWNER, "status": "active"})
|
||||||
|
CreditAccount.objects.get_or_create(team=team, defaults={"balance": "9999.0000"})
|
||||||
|
|
||||||
|
old, _ = Product.objects.get_or_create(
|
||||||
|
team=team, title="夜光面膜", defaults={"created_by": user, "category": "美妆护肤", "target_audience": "熬夜上班族"}
|
||||||
|
)
|
||||||
|
new, _ = Product.objects.get_or_create(
|
||||||
|
team=team, title="鎏金精华水", defaults={"created_by": user, "category": "美妆护肤", "target_audience": "都市白领"}
|
||||||
|
)
|
||||||
|
|
||||||
|
project, _ = Project.objects.get_or_create(
|
||||||
|
team=team,
|
||||||
|
name="夜光面膜 · 短视频 · v1",
|
||||||
|
defaults={
|
||||||
|
"created_by": user,
|
||||||
|
"product": old,
|
||||||
|
"metadata": {"wizard": {"persona": "urban", "presentation_format": "oral", "video_structure": "pain", "total_duration": 30}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for stage in STAGE_ORDER:
|
||||||
|
ProjectStage.objects.get_or_create(project=project, stage=stage)
|
||||||
|
|
||||||
|
script, made = ScriptVersion.objects.get_or_create(
|
||||||
|
project=project,
|
||||||
|
title="熬夜脸急救",
|
||||||
|
defaults={
|
||||||
|
"content": "{}",
|
||||||
|
"source": "ai",
|
||||||
|
"is_adopted": True,
|
||||||
|
"metadata": {"presentation_format": "oral", "video_structure": "pain", "total_duration": 30, "hook": "熬夜脸还有救吗"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if made:
|
||||||
|
for order, duration, role, exposure, narration in [
|
||||||
|
(0, 5, "钩子", "未出现", "连着熬三天,这张脸还有救吗?"),
|
||||||
|
(1, 8, "痛点", "背景陈列", "早上照镜子,毛孔粗、脸发黄,粉底都盖不住。"),
|
||||||
|
(2, 12, "卖点", "手持展示", "这盒夜光面膜敷 10 分钟,精华浓度是普通款的三倍。"),
|
||||||
|
(3, 5, "CTA", "特写", "点下方小黄车,今晚下单买二送一。"),
|
||||||
|
]:
|
||||||
|
ScriptSegment.objects.create(
|
||||||
|
script_version=script,
|
||||||
|
sort_order=order,
|
||||||
|
duration_seconds=duration,
|
||||||
|
role=role,
|
||||||
|
product_exposure=exposure,
|
||||||
|
narration=narration,
|
||||||
|
visual_prompt=f"{role}镜:{narration}",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("project", project.id)
|
||||||
|
print("old_product", old.id, "new_product", new.id)
|
||||||
@@ -14,6 +14,7 @@ urlpatterns = [
|
|||||||
path("api/assets/", include("apps.assets.urls")),
|
path("api/assets/", include("apps.assets.urls")),
|
||||||
path("api/models/", include("apps.assets.model_urls")),
|
path("api/models/", include("apps.assets.model_urls")),
|
||||||
path("api/projects/", include("apps.projects.urls")),
|
path("api/projects/", include("apps.projects.urls")),
|
||||||
|
path("api/script-templates/", include("apps.projects.template_urls")),
|
||||||
path("api/billing/", include("apps.billing.urls")),
|
path("api/billing/", include("apps.billing.urls")),
|
||||||
path("api/ai/", include("apps.ai.urls")),
|
path("api/ai/", include("apps.ai.urls")),
|
||||||
path("api/ops/", include("apps.ops.urls")),
|
path("api/ops/", include("apps.ops.urls")),
|
||||||
|
|||||||
@@ -195,10 +195,16 @@ def _product_context(project, selling_point_ids: list[str] | None) -> str:
|
|||||||
if selling_point_ids:
|
if selling_point_ids:
|
||||||
selling_points = selling_points.filter(id__in=selling_point_ids)
|
selling_points = selling_points.filter(id__in=selling_point_ids)
|
||||||
selling_text = "\n".join(f"- {sp.title}:{sp.detail}" for sp in selling_points)
|
selling_text = "\n".join(f"- {sp.title}:{sp.detail}" for sp in selling_points)
|
||||||
|
business_type = getattr(product, "business_type", "") or "ecommerce"
|
||||||
|
if business_type == "local_life":
|
||||||
|
type_line = "业务类型:本地生活(团购/到店核销,无实物,按虚拟商品走主流程 SOP)\n"
|
||||||
|
else:
|
||||||
|
type_line = "业务类型:电商(实物商品)\n"
|
||||||
return (
|
return (
|
||||||
f"商品标题:{product.title}\n"
|
f"商品标题:{product.title}\n"
|
||||||
f"品牌:{product.brand or '未填写'}\n"
|
f"品牌:{product.brand or '未填写'}\n"
|
||||||
f"类目:{product.category or '未填写'}\n"
|
f"{type_line}"
|
||||||
|
f"品类:{product.category or '未填写'}\n"
|
||||||
f"目标人群:{product.target_audience or '未填写'}\n"
|
f"目标人群:{product.target_audience or '未填写'}\n"
|
||||||
f"商品描述:{product.description or '未填写'}\n"
|
f"商品描述:{product.description or '未填写'}\n"
|
||||||
f"卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼。'}"
|
f"卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼。'}"
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("products", "0003_product_purged_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="product",
|
||||||
|
name="business_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("ecommerce", "电商"), ("local_life", "本地生活")],
|
||||||
|
default="ecommerce",
|
||||||
|
max_length=24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="product",
|
||||||
|
index=models.Index(fields=["team", "business_type"], name="products_pr_team_biz_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -8,8 +8,16 @@ class Product(TeamOwnedModel):
|
|||||||
ACTIVE = "active", "Active"
|
ACTIVE = "active", "Active"
|
||||||
ARCHIVED = "archived", "Archived"
|
ARCHIVED = "archived", "Archived"
|
||||||
|
|
||||||
|
class BusinessType(models.TextChoices):
|
||||||
|
ECOMMERCE = "ecommerce", "电商"
|
||||||
|
LOCAL_LIFE = "local_life", "本地生活"
|
||||||
|
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
brand = models.CharField(max_length=128, blank=True)
|
brand = models.CharField(max_length=128, blank=True)
|
||||||
|
# 类目(电商/本地生活)≠品类(美妆/食品)。类目驱动节点屏蔽与后续 skill 挂载。
|
||||||
|
business_type = models.CharField(
|
||||||
|
max_length=24, choices=BusinessType.choices, default=BusinessType.ECOMMERCE
|
||||||
|
)
|
||||||
category = models.CharField(max_length=128, blank=True)
|
category = models.CharField(max_length=128, blank=True)
|
||||||
target_audience = models.CharField(max_length=255, blank=True)
|
target_audience = models.CharField(max_length=255, blank=True)
|
||||||
specs = models.JSONField(default=dict, blank=True)
|
specs = models.JSONField(default=dict, blank=True)
|
||||||
@@ -29,6 +37,7 @@ class Product(TeamOwnedModel):
|
|||||||
models.Index(fields=["team", "status"]),
|
models.Index(fields=["team", "status"]),
|
||||||
models.Index(fields=["team", "status", "purged_at"]),
|
models.Index(fields=["team", "status", "purged_at"]),
|
||||||
models.Index(fields=["team", "category"]),
|
models.Index(fields=["team", "category"]),
|
||||||
|
models.Index(fields=["team", "business_type"]),
|
||||||
# 商品库默认按团队 + 创建时间倒序翻页
|
# 商品库默认按团队 + 创建时间倒序翻页
|
||||||
models.Index(fields=["team", "-created_at"]),
|
models.Index(fields=["team", "-created_at"]),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class ProductSerializer(serializers.ModelSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"title",
|
"title",
|
||||||
"brand",
|
"brand",
|
||||||
|
"business_type",
|
||||||
"category",
|
"category",
|
||||||
"target_audience",
|
"target_audience",
|
||||||
"specs",
|
"specs",
|
||||||
@@ -62,9 +63,12 @@ class ProductSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
# 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。
|
# 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。
|
||||||
|
# 例外:本地生活团购是虚拟商品(无实物),允许不传主图,沿用主流程 SOP。
|
||||||
# 前端抽屉走 upload-first(拖入即传 /assets/upload/ 拿 asset → 创建时带 cover_asset/images 进来),
|
# 前端抽屉走 upload-first(拖入即传 /assets/upload/ 拿 asset → 创建时带 cover_asset/images 进来),
|
||||||
# 所以这道校验是可满足的、且正确。只在创建时强制,编辑已有商品不阻断。
|
# 所以这道校验对电商是可满足的、且正确。只在创建时强制,编辑已有商品不阻断。
|
||||||
if self.instance is None and not attrs.get("cover_asset") and not attrs.get("images"):
|
if self.instance is None and not attrs.get("cover_asset") and not attrs.get("images"):
|
||||||
|
business_type = attrs.get("business_type") or Product.BusinessType.ECOMMERCE
|
||||||
|
if business_type != Product.BusinessType.LOCAL_LIFE:
|
||||||
raise serializers.ValidationError({"images": "创建商品至少需要上传一张商品图片"})
|
raise serializers.ValidationError({"images": "创建商品至少需要上传一张商品图片"})
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -187,3 +187,58 @@ class ProductImageDeleteGuardTests(TestCase):
|
|||||||
self.assertEqual(res.status_code, 200)
|
self.assertEqual(res.status_code, 200)
|
||||||
self.product.refresh_from_db()
|
self.product.refresh_from_db()
|
||||||
self.assertEqual(self.product.cover_asset_id, self.img0.asset_id) # 封面不受影响
|
self.assertEqual(self.product.cover_asset_id, self.img0.asset_id) # 封面不受影响
|
||||||
|
|
||||||
|
|
||||||
|
class ProductBusinessTypeTests(TestCase):
|
||||||
|
"""3.1 类目字段 + 3.2 本地生活团购允许无实物主图。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="bt", password="x")
|
||||||
|
self.team = Team.objects.create(name="BT", owner=self.user)
|
||||||
|
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
def test_default_business_type_is_ecommerce(self):
|
||||||
|
product = Product.objects.create(team=self.team, created_by=self.user, title="面膜")
|
||||||
|
self.assertEqual(product.business_type, Product.BusinessType.ECOMMERCE)
|
||||||
|
data = self.client.get(f"/api/products/{product.id}/").json()
|
||||||
|
self.assertEqual(data["business_type"], "ecommerce")
|
||||||
|
|
||||||
|
def test_ecommerce_create_requires_image(self):
|
||||||
|
res = self.client.post(
|
||||||
|
"/api/products/",
|
||||||
|
{"title": "面膜", "business_type": "ecommerce", "category": "美妆个护"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertIn("images", res.json())
|
||||||
|
|
||||||
|
def test_local_life_create_without_image(self):
|
||||||
|
res = self.client.post(
|
||||||
|
"/api/products/",
|
||||||
|
{
|
||||||
|
"title": "双人下午茶套餐",
|
||||||
|
"business_type": "local_life",
|
||||||
|
"category": "餐饮美食",
|
||||||
|
"selling_points": [{"title": "周末通用", "detail": "周末通用", "sort_order": 0}],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 201, res.content)
|
||||||
|
data = res.json()
|
||||||
|
self.assertEqual(data["business_type"], "local_life")
|
||||||
|
self.assertEqual(data["category"], "餐饮美食")
|
||||||
|
self.assertEqual(data["images"], [])
|
||||||
|
self.assertTrue(Product.objects.filter(id=data["id"], business_type="local_life").exists())
|
||||||
|
|
||||||
|
def test_list_filter_by_business_type(self):
|
||||||
|
Product.objects.create(team=self.team, created_by=self.user, title="面膜", business_type="ecommerce")
|
||||||
|
Product.objects.create(team=self.team, created_by=self.user, title="火锅套餐", business_type="local_life")
|
||||||
|
ecommerce = [p["title"] for p in self.client.get("/api/products/?business_type=ecommerce").json()["results"]]
|
||||||
|
local = [p["title"] for p in self.client.get("/api/products/?business_type=local_life").json()["results"]]
|
||||||
|
self.assertIn("面膜", ecommerce)
|
||||||
|
self.assertNotIn("火锅套餐", ecommerce)
|
||||||
|
self.assertIn("火锅套餐", local)
|
||||||
|
self.assertNotIn("面膜", local)
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,13 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
# 回收站相关动作(trash/restore/purge)才看 archived。
|
# 回收站相关动作(trash/restore/purge)才看 archived。
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
if self.action in ("trash", "restore", "purge"):
|
if self.action in ("trash", "restore", "purge"):
|
||||||
return qs.filter(status=Product.Status.ARCHIVED, purged_at__isnull=True)
|
qs = qs.filter(status=Product.Status.ARCHIVED, purged_at__isnull=True)
|
||||||
return qs.filter(status=Product.Status.ACTIVE, purged_at__isnull=True)
|
else:
|
||||||
|
qs = qs.filter(status=Product.Status.ACTIVE, purged_at__isnull=True)
|
||||||
|
business_type = self.request.query_params.get("business_type")
|
||||||
|
if business_type in dict(Product.BusinessType.choices):
|
||||||
|
qs = qs.filter(business_type=business_type)
|
||||||
|
return qs
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
# 删除 = 软删进垃圾桶(可恢复),不真删数据
|
# 删除 = 软删进垃圾桶(可恢复),不真删数据
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-08-18 03:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0009_team_price_multiplier'),
|
||||||
|
('projects', '0007_project_soft_delete'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScriptTemplate',
|
||||||
|
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)),
|
||||||
|
('presentation_format', models.CharField(blank=True, max_length=32)),
|
||||||
|
('video_structure', models.CharField(blank=True, max_length=32)),
|
||||||
|
('persona', models.CharField(blank=True, max_length=32)),
|
||||||
|
('total_duration', models.PositiveIntegerField(default=0)),
|
||||||
|
('outline', models.JSONField(blank=True, default=list)),
|
||||||
|
('cta', models.TextField(blank=True)),
|
||||||
|
('scenes', models.JSONField(blank=True, default=list)),
|
||||||
|
('usage_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('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)),
|
||||||
|
('source_project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='script_templates', to='projects.project')),
|
||||||
|
('source_script', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='templates', to='projects.scriptversion')),
|
||||||
|
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='accounts.team')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['team', '-created_at'], name='projects_sc_team_id_da7bc5_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('team', 'name'), name='uniq_script_template_name_per_team')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -100,6 +100,43 @@ class ScriptSegment(TimeStampedModel):
|
|||||||
ordering = ["sort_order", "created_at"]
|
ordering = ["sort_order", "created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptTemplate(TeamOwnedModel):
|
||||||
|
"""「套路模板」:把一版脚本里可复用的部分抽出来存团队库,换个商品重开项目时套用。
|
||||||
|
|
||||||
|
只存**套路**不存**文案**:outline 里每镜只留作用/时长/商品露出/说话方式,
|
||||||
|
旧商品的口播词一律不进模板,避免换商品重跑时把上一个品牌漏出去。
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=128)
|
||||||
|
presentation_format = models.CharField(max_length=32, blank=True) # oral|drama|vlog
|
||||||
|
video_structure = models.CharField(max_length=32, blank=True) # pain|contrast|review|scene
|
||||||
|
persona = models.CharField(max_length=32, blank=True) # 人物设定 key(对齐向导 WIZ_PERSONAS)
|
||||||
|
total_duration = models.PositiveIntegerField(default=0)
|
||||||
|
# [{index, role, duration, product_exposure, delivery}] · 镜头节奏 + 脚本结构
|
||||||
|
outline = models.JSONField(default=list, blank=True)
|
||||||
|
# 结尾转化方式的**写法参考**(可能带旧品牌词,套用时提示模型换成新商品)
|
||||||
|
cta = models.TextField(blank=True)
|
||||||
|
# 场景风格名(人物场景风格的场景侧;人物侧由 persona 承载)
|
||||||
|
scenes = models.JSONField(default=list, blank=True)
|
||||||
|
source_project = models.ForeignKey(
|
||||||
|
Project, on_delete=models.SET_NULL, null=True, blank=True, related_name="script_templates"
|
||||||
|
)
|
||||||
|
source_script = models.ForeignKey(
|
||||||
|
"ScriptVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="templates"
|
||||||
|
)
|
||||||
|
usage_count = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["team", "name"], name="uniq_script_template_name_per_team"),
|
||||||
|
]
|
||||||
|
indexes = [models.Index(fields=["team", "-created_at"])]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class BaseAssetGroup(TimeStampedModel):
|
class BaseAssetGroup(TimeStampedModel):
|
||||||
class Kind(models.TextChoices):
|
class Kind(models.TextChoices):
|
||||||
PRODUCT = "product", "Product"
|
PRODUCT = "product", "Product"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .models import (
|
|||||||
Project,
|
Project,
|
||||||
ProjectStage,
|
ProjectStage,
|
||||||
ScriptSegment,
|
ScriptSegment,
|
||||||
|
ScriptTemplate,
|
||||||
ScriptVersion,
|
ScriptVersion,
|
||||||
StoryboardFrame,
|
StoryboardFrame,
|
||||||
StoryboardShot,
|
StoryboardShot,
|
||||||
@@ -34,6 +35,41 @@ def _asset_preview_url(asset) -> str:
|
|||||||
return AssetFileSerializer().get_preview_url(primary) if primary else ""
|
return AssetFileSerializer().get_preview_url(primary) if primary else ""
|
||||||
|
|
||||||
|
|
||||||
|
_VIDEO_SUFFIXES = (".mp4", ".mov", ".webm", ".m4v")
|
||||||
|
|
||||||
|
|
||||||
|
def is_playable_video(asset) -> bool:
|
||||||
|
"""主文件是不是真能塞进 <video> 播的视频。演示种子数据里有「导出任务标成功、挂的却是一张 PNG 海报」
|
||||||
|
的成片,直接下发会让播放按钮打开一个放不出来的图。"""
|
||||||
|
if asset is None:
|
||||||
|
return False
|
||||||
|
files = list(asset.files.all())
|
||||||
|
primary = next((f for f in files if f.is_primary), files[0] if files else None)
|
||||||
|
if primary is None:
|
||||||
|
return False
|
||||||
|
if (primary.content_type or "").startswith("video/"):
|
||||||
|
return True
|
||||||
|
return (primary.object_key or "").lower().endswith(_VIDEO_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _final_video_url(project) -> str:
|
||||||
|
"""合成成片(最新一次成功的拼接导出)的可播放 URL;没合成过返回空串。
|
||||||
|
在 Python 侧筛选而不再查库:列表页用 Prefetch 预取(见 ProjectViewSet.get_queryset),
|
||||||
|
详情页 timeline 只有一条、export_jobs 数量极少,直接遍历不会放大查询。"""
|
||||||
|
timeline = getattr(project, "timeline", None)
|
||||||
|
if timeline is None:
|
||||||
|
return ""
|
||||||
|
succeeded = [
|
||||||
|
job for job in timeline.export_jobs.all()
|
||||||
|
if job.status == ExportJob.Status.SUCCEEDED and is_playable_video(job.output_asset)
|
||||||
|
]
|
||||||
|
if not succeeded:
|
||||||
|
return ""
|
||||||
|
dated = [job for job in succeeded if job.created_at is not None]
|
||||||
|
latest = max(dated, key=lambda job: job.created_at) if dated else succeeded[-1]
|
||||||
|
return _asset_preview_url(latest.output_asset)
|
||||||
|
|
||||||
|
|
||||||
class ProjectStageSerializer(serializers.ModelSerializer):
|
class ProjectStageSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProjectStage
|
model = ProjectStage
|
||||||
@@ -321,18 +357,24 @@ class ProjectListSerializer(serializers.ModelSerializer):
|
|||||||
cover_preview_url = serializers.SerializerMethodField()
|
cover_preview_url = serializers.SerializerMethodField()
|
||||||
script_version_count = serializers.IntegerField(read_only=True, default=0)
|
script_version_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
video_segment_count = serializers.IntegerField(read_only=True, default=0)
|
video_segment_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
|
# 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线)
|
||||||
|
final_video_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
fields = [
|
fields = [
|
||||||
"id", "name", "product", "product_title", "cover_preview_url",
|
"id", "name", "product", "product_title", "cover_preview_url",
|
||||||
"status", "current_stage", "script_version_count", "video_segment_count",
|
"status", "current_stage", "script_version_count", "video_segment_count",
|
||||||
|
"final_video_url",
|
||||||
"is_deleted", "purged_at", "created_at", "updated_at",
|
"is_deleted", "purged_at", "created_at", "updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_cover_preview_url(self, obj) -> str:
|
def get_cover_preview_url(self, obj) -> str:
|
||||||
return _asset_preview_url(getattr(obj.product, "cover_asset", None)) if obj.product_id else ""
|
return _asset_preview_url(getattr(obj.product, "cover_asset", None)) if obj.product_id else ""
|
||||||
|
|
||||||
|
def get_final_video_url(self, obj) -> str:
|
||||||
|
return _final_video_url(obj)
|
||||||
|
|
||||||
|
|
||||||
class ProjectSerializer(serializers.ModelSerializer):
|
class ProjectSerializer(serializers.ModelSerializer):
|
||||||
stages = ProjectStageSerializer(many=True, read_only=True)
|
stages = ProjectStageSerializer(many=True, read_only=True)
|
||||||
@@ -342,6 +384,8 @@ class ProjectSerializer(serializers.ModelSerializer):
|
|||||||
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写)
|
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写)
|
||||||
storyboard_shots = StoryboardShotSerializer(many=True, read_only=True)
|
storyboard_shots = StoryboardShotSerializer(many=True, read_only=True)
|
||||||
timeline = TimelineSerializer(read_only=True)
|
timeline = TimelineSerializer(read_only=True)
|
||||||
|
# 合成成片地址(最新一次成功拼接):视频阶段的「播放成片 / 下载成片」直接用它
|
||||||
|
final_video_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
@@ -363,7 +407,64 @@ class ProjectSerializer(serializers.ModelSerializer):
|
|||||||
"storyboard_shots",
|
"storyboard_shots",
|
||||||
"video_segments",
|
"video_segments",
|
||||||
"timeline",
|
"timeline",
|
||||||
|
"final_video_url",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "status", "current_stage", "failure_reason", "is_deleted", "purged_at", "created_at", "updated_at"]
|
read_only_fields = ["id", "status", "current_stage", "failure_reason", "is_deleted", "purged_at", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def get_final_video_url(self, obj) -> str:
|
||||||
|
return _final_video_url(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptTemplateSerializer(serializers.ModelSerializer):
|
||||||
|
"""套路模板 · 列表与详情共用。写入只开放 name(其余字段由存模板端点从脚本抽)。"""
|
||||||
|
|
||||||
|
outline_text = serializers.SerializerMethodField()
|
||||||
|
source_project_name = serializers.CharField(source="source_project.name", default="", read_only=True)
|
||||||
|
shot_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ScriptTemplate
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"presentation_format",
|
||||||
|
"video_structure",
|
||||||
|
"persona",
|
||||||
|
"total_duration",
|
||||||
|
"outline",
|
||||||
|
"outline_text",
|
||||||
|
"shot_count",
|
||||||
|
"cta",
|
||||||
|
"scenes",
|
||||||
|
"source_project",
|
||||||
|
"source_project_name",
|
||||||
|
"usage_count",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"presentation_format",
|
||||||
|
"video_structure",
|
||||||
|
"persona",
|
||||||
|
"total_duration",
|
||||||
|
"outline",
|
||||||
|
"cta",
|
||||||
|
"scenes",
|
||||||
|
"source_project",
|
||||||
|
"usage_count",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_outline_text(self, obj) -> str:
|
||||||
|
from .services.templates import render_outline_text
|
||||||
|
|
||||||
|
return render_outline_text(
|
||||||
|
{"outline": obj.outline, "cta": obj.cta}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_shot_count(self, obj) -> int:
|
||||||
|
return len(obj.outline or [])
|
||||||
|
|||||||
@@ -442,6 +442,10 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
|||||||
suffix = Path(primary.object_key).suffix or ".mp3" if primary else ".mp3"
|
suffix = Path(primary.object_key).suffix or ".mp3" if primary else ".mp3"
|
||||||
bgm_name = f"bgm{suffix}"
|
bgm_name = f"bgm{suffix}"
|
||||||
_download_asset_primary_file(bgm_track.asset, tmp / bgm_name)
|
_download_asset_primary_file(bgm_track.asset, tmp / bgm_name)
|
||||||
|
# BGM 文件里没有音频流(上传串了张图 / 演示种子数据)→ 丢掉 BGM 照常拼片,
|
||||||
|
# 而不是让滤镜里的 [n:a] 匹配不到流、把整条合成炸成一句看不懂的 ffmpeg 报错。
|
||||||
|
if not _has_audio_stream(tmp / bgm_name):
|
||||||
|
bgm_name = None
|
||||||
|
|
||||||
cues = _subtitle_cues(timeline, project, specs, starts, total)
|
cues = _subtitle_cues(timeline, project, specs, starts, total)
|
||||||
sub_overlays: list[tuple[str, float, float]] = []
|
sub_overlays: list[tuple[str, float, float]] = []
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""套路模板:从一版脚本抽出可复用的结构,以及把模板还原成给脚本 agent 的指令。
|
||||||
|
|
||||||
|
设计前提:模板只承载**怎么讲**(镜数、每镜作用、节奏、商品怎么露、口播还是对白、转化写法),
|
||||||
|
不承载**讲什么**(旧商品的口播词)。换商品重跑时才不会把上一个品牌带出去。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
ROLE_FALLBACK = "叙述"
|
||||||
|
DELIVERY_DIALOGUE = "角色对白"
|
||||||
|
DELIVERY_NARRATION = "口播/旁白"
|
||||||
|
|
||||||
|
# 脚本 agent 落库存中文标签(「短剧」),设定卡 / 模板表用 ASCII key(drama)。两边都认。
|
||||||
|
# 人物同理:一期向导偶尔把中文写进 wizard.persona。
|
||||||
|
_PERSONA_KEYS = {"urban", "bestie", "ceo", "reviewer", "mom", "genz"}
|
||||||
|
_PERSONA_KEY_BY_LABEL = {
|
||||||
|
"都市白领女性": "urban",
|
||||||
|
"闺蜜种草": "bestie",
|
||||||
|
"总裁亲选": "ceo",
|
||||||
|
"专业测评师": "reviewer",
|
||||||
|
"专业测评": "reviewer",
|
||||||
|
"实用宝妈": "mom",
|
||||||
|
"学生党": "genz",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_template_combo(presentation_format: str, video_structure: str) -> tuple[str, str]:
|
||||||
|
from apps.ai.script_agent import combo_keys
|
||||||
|
|
||||||
|
return combo_keys(presentation_format or None, video_structure or None)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_persona(value: str) -> str:
|
||||||
|
raw = (value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if raw in _PERSONA_KEYS:
|
||||||
|
return raw
|
||||||
|
return _PERSONA_KEY_BY_LABEL.get(raw, raw)
|
||||||
|
|
||||||
|
|
||||||
|
def build_template_fields(*, project, script) -> dict:
|
||||||
|
"""从 ScriptVersion 抽出模板字段。不落库,调用方决定怎么存。"""
|
||||||
|
meta = script.metadata or {}
|
||||||
|
wizard = (project.metadata or {}).get("wizard") or {}
|
||||||
|
segments = list(script.segments.all())
|
||||||
|
|
||||||
|
outline = []
|
||||||
|
cta = ""
|
||||||
|
for index, segment in enumerate(segments):
|
||||||
|
role = (segment.role or "").strip() or ROLE_FALLBACK
|
||||||
|
outline.append(
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"role": role,
|
||||||
|
"duration": int(segment.duration_seconds or 0),
|
||||||
|
"product_exposure": (segment.product_exposure or "").strip(),
|
||||||
|
"delivery": DELIVERY_DIALOGUE if segment.dialogue else DELIVERY_NARRATION,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if role == "CTA":
|
||||||
|
cta = (segment.narration or "").strip()
|
||||||
|
if not cta and segments:
|
||||||
|
cta = (segments[-1].narration or "").strip()
|
||||||
|
|
||||||
|
total = meta.get("total_duration")
|
||||||
|
if not isinstance(total, int) or total <= 0:
|
||||||
|
total = sum(item["duration"] for item in outline)
|
||||||
|
|
||||||
|
raw_format = str(meta.get("presentation_format") or wizard.get("presentation_format") or "")
|
||||||
|
raw_structure = str(meta.get("video_structure") or wizard.get("video_structure") or "")
|
||||||
|
fmt, structure = coerce_template_combo(raw_format, raw_structure) if (raw_format or raw_structure) else ("", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"presentation_format": fmt,
|
||||||
|
"video_structure": structure,
|
||||||
|
"persona": coerce_persona(str(wizard.get("persona") or "")),
|
||||||
|
"total_duration": total,
|
||||||
|
"outline": outline,
|
||||||
|
"cta": cta[:500],
|
||||||
|
"scenes": [s for s in ((project.metadata or {}).get("scenes") or []) if isinstance(s, str)][:6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_outline_text(template_fields: dict) -> str:
|
||||||
|
"""把模板渲染成人能读、模型也能照着做的一段中文,供前端预览与生成时拼 prompt 复用。"""
|
||||||
|
outline = template_fields.get("outline") or []
|
||||||
|
lines = []
|
||||||
|
for item in outline:
|
||||||
|
parts = [f"第 {int(item.get('index', 0)) + 1} 镜", f"{item.get('duration', 0)}s", str(item.get("role") or ROLE_FALLBACK)]
|
||||||
|
exposure = str(item.get("product_exposure") or "").strip()
|
||||||
|
if exposure:
|
||||||
|
parts.append(f"商品{exposure}")
|
||||||
|
parts.append(str(item.get("delivery") or DELIVERY_NARRATION))
|
||||||
|
lines.append(" · ".join(parts))
|
||||||
|
body = "\n".join(lines)
|
||||||
|
cta = str(template_fields.get("cta") or "").strip()
|
||||||
|
if cta:
|
||||||
|
body += f"\n结尾转化写法参考:{cta}"
|
||||||
|
return body
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import ScriptTemplateViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register("", ScriptTemplateViewSet, basename="script-template")
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""5.1 存模板 / 5.2 换商品重跑 · 端到端契约测试。
|
||||||
|
|
||||||
|
关注三件事:模板只抽套路不抽文案、团队之间互不可见、新建项目时套路参数按库里的模板回填。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.accounts.models import Team, TeamMember, User
|
||||||
|
from apps.products.models import Product
|
||||||
|
from apps.projects.models import Project, ScriptSegment, ScriptTemplate, ScriptVersion
|
||||||
|
|
||||||
|
|
||||||
|
def _make_team(username: str, team_name: str):
|
||||||
|
user = User.objects.create_user(username=username, password="pass")
|
||||||
|
team = Team.objects.create(name=team_name, owner=user)
|
||||||
|
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
|
||||||
|
return user, team
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptTemplateTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user, self.team = _make_team("tpl-owner", "Tpl Team")
|
||||||
|
self.product = Product.objects.create(team=self.team, created_by=self.user, title="旧商品 · 面膜")
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
team=self.team,
|
||||||
|
created_by=self.user,
|
||||||
|
name="面膜 · v1",
|
||||||
|
product=self.product,
|
||||||
|
metadata={"wizard": {"persona": "urban"}, "scenes": ["出租屋卫生间", "梳妆台"]},
|
||||||
|
)
|
||||||
|
self.script = ScriptVersion.objects.create(
|
||||||
|
project=self.project,
|
||||||
|
title="熬夜脸急救",
|
||||||
|
content="{}",
|
||||||
|
source="ai",
|
||||||
|
is_adopted=True,
|
||||||
|
metadata={"presentation_format": "oral", "video_structure": "pain", "total_duration": 30},
|
||||||
|
)
|
||||||
|
specs = [
|
||||||
|
(0, 5, "钩子", "未出现", "熬夜脸还有救吗"),
|
||||||
|
(1, 8, "痛点", "背景陈列", "第二天开会脸垮得像纸"),
|
||||||
|
(2, 12, "卖点", "手持展示", "这盒面膜敷 10 分钟就回弹"),
|
||||||
|
(3, 5, "CTA", "特写", "点下方小黄车,今晚买二送一"),
|
||||||
|
]
|
||||||
|
for order, duration, role, exposure, narration in specs:
|
||||||
|
ScriptSegment.objects.create(
|
||||||
|
script_version=self.script,
|
||||||
|
sort_order=order,
|
||||||
|
duration_seconds=duration,
|
||||||
|
role=role,
|
||||||
|
product_exposure=exposure,
|
||||||
|
narration=narration,
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
# ── 5.1 存模板 ──
|
||||||
|
def test_save_as_template_captures_structure_not_copy(self):
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/projects/{self.project.id}/save-as-template/", {"name": "口播 · 痛点前置"}, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
|
template = ScriptTemplate.objects.get(id=response.data["id"])
|
||||||
|
self.assertEqual(template.team, self.team)
|
||||||
|
self.assertEqual(template.presentation_format, "oral")
|
||||||
|
self.assertEqual(template.video_structure, "pain")
|
||||||
|
self.assertEqual(template.persona, "urban")
|
||||||
|
self.assertEqual(template.total_duration, 30)
|
||||||
|
self.assertEqual([item["role"] for item in template.outline], ["钩子", "痛点", "卖点", "CTA"])
|
||||||
|
self.assertEqual([item["duration"] for item in template.outline], [5, 8, 12, 5])
|
||||||
|
self.assertEqual(template.scenes, ["出租屋卫生间", "梳妆台"])
|
||||||
|
# 转化写法作为参考留一句,其余镜的口播文案一律不进模板
|
||||||
|
self.assertEqual(template.cta, "点下方小黄车,今晚买二送一")
|
||||||
|
blob = "".join(str(item) for item in template.outline)
|
||||||
|
for copy in ("熬夜脸还有救吗", "脸垮得像纸", "敷 10 分钟就回弹"):
|
||||||
|
self.assertNotIn(copy, blob)
|
||||||
|
|
||||||
|
def test_chinese_labels_from_script_are_normalized_to_keys(self):
|
||||||
|
"""脚本 agent 落库存的是中文标签;模板必须转成 key,否则设定卡会显示 undefined。"""
|
||||||
|
self.script.metadata = {
|
||||||
|
**self.script.metadata,
|
||||||
|
"presentation_format": "短剧",
|
||||||
|
"video_structure": "痛点解决",
|
||||||
|
}
|
||||||
|
self.script.save(update_fields=["metadata"])
|
||||||
|
self.project.metadata = {"wizard": {"persona": "专业测评"}}
|
||||||
|
self.project.save(update_fields=["metadata"])
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
f"/api/projects/{self.project.id}/save-as-template/", {"name": "短剧 · 痛点解决"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201, created.data)
|
||||||
|
template = ScriptTemplate.objects.get(id=created.data["id"])
|
||||||
|
self.assertEqual(template.presentation_format, "drama")
|
||||||
|
self.assertEqual(template.video_structure, "pain")
|
||||||
|
self.assertEqual(template.persona, "reviewer")
|
||||||
|
|
||||||
|
new_product = Product.objects.create(team=self.team, created_by=self.user, title="新商品 · T恤")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{
|
||||||
|
"name": "T恤 · 同套路",
|
||||||
|
"product": str(new_product.id),
|
||||||
|
"metadata": {"wizard": {"template_id": created.data["id"]}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
|
wizard = Project.objects.get(id=response.data["id"]).metadata["wizard"]
|
||||||
|
self.assertEqual(wizard["presentation_format"], "drama")
|
||||||
|
self.assertEqual(wizard["video_structure"], "pain")
|
||||||
|
self.assertEqual(wizard["persona"], "reviewer")
|
||||||
|
|
||||||
|
def test_legacy_chinese_template_is_coerced_on_apply(self):
|
||||||
|
"""已经存进库的旧模板若带着中文标签,新建项目时也要转成 key。"""
|
||||||
|
template = ScriptTemplate.objects.create(
|
||||||
|
team=self.team,
|
||||||
|
created_by=self.user,
|
||||||
|
name="旧短剧模板",
|
||||||
|
presentation_format="短剧",
|
||||||
|
video_structure="痛点解决",
|
||||||
|
persona="专业测评",
|
||||||
|
total_duration=50,
|
||||||
|
outline=[{"index": 0, "role": "钩子", "duration": 5, "product_exposure": "", "delivery": "口播/旁白"}],
|
||||||
|
)
|
||||||
|
new_product = Product.objects.create(team=self.team, created_by=self.user, title="新商品 · 精华")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{
|
||||||
|
"name": "精华 · 同套路",
|
||||||
|
"product": str(new_product.id),
|
||||||
|
"metadata": {"wizard": {"template_id": str(template.id)}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
|
wizard = Project.objects.get(id=response.data["id"]).metadata["wizard"]
|
||||||
|
self.assertEqual(wizard["presentation_format"], "drama")
|
||||||
|
self.assertEqual(wizard["video_structure"], "pain")
|
||||||
|
self.assertEqual(wizard["persona"], "reviewer")
|
||||||
|
self.assertEqual(wizard["total_duration"], 50)
|
||||||
|
|
||||||
|
def test_outline_text_is_readable(self):
|
||||||
|
self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||||
|
listed = self.client.get("/api/script-templates/")
|
||||||
|
|
||||||
|
text = listed.data["results"][0]["outline_text"]
|
||||||
|
self.assertIn("第 1 镜 · 5s · 钩子", text)
|
||||||
|
self.assertIn("商品手持展示", text)
|
||||||
|
self.assertIn("结尾转化写法参考:", text)
|
||||||
|
self.assertEqual(listed.data["results"][0]["shot_count"], 4)
|
||||||
|
|
||||||
|
def test_save_rejects_short_name_duplicate_and_scriptless_project(self):
|
||||||
|
short = self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "A"}, format="json")
|
||||||
|
self.assertEqual(short.status_code, 400)
|
||||||
|
|
||||||
|
self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||||
|
dupe = self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||||
|
self.assertEqual(dupe.status_code, 400)
|
||||||
|
self.assertEqual(ScriptTemplate.objects.filter(team=self.team).count(), 1)
|
||||||
|
|
||||||
|
empty = Project.objects.create(team=self.team, created_by=self.user, name="空项目", product=self.product)
|
||||||
|
blank = self.client.post(f"/api/projects/{empty.id}/save-as-template/", {"name": "套路 B"}, format="json")
|
||||||
|
self.assertEqual(blank.status_code, 400)
|
||||||
|
|
||||||
|
def test_templates_are_team_scoped(self):
|
||||||
|
self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||||
|
other_user, _ = _make_team("outsider", "Other Team")
|
||||||
|
other = APIClient()
|
||||||
|
other.force_authenticate(other_user)
|
||||||
|
|
||||||
|
self.assertEqual(other.get("/api/script-templates/").data["count"], 0)
|
||||||
|
|
||||||
|
# ── 5.2 换商品重跑 ──
|
||||||
|
def test_new_project_with_template_backfills_wizard(self):
|
||||||
|
created = self.client.post(
|
||||||
|
f"/api/projects/{self.project.id}/save-as-template/", {"name": "口播 · 痛点前置"}, format="json"
|
||||||
|
)
|
||||||
|
template_id = created.data["id"]
|
||||||
|
new_product = Product.objects.create(team=self.team, created_by=self.user, title="新商品 · 精华水")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{
|
||||||
|
"name": "精华水 · v1",
|
||||||
|
"product": str(new_product.id),
|
||||||
|
"metadata": {"wizard": {"template_id": template_id, "selling_point_ids": []}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
|
project = Project.objects.get(id=response.data["id"])
|
||||||
|
wizard = project.metadata["wizard"]
|
||||||
|
self.assertEqual(wizard["template_id"], template_id)
|
||||||
|
self.assertEqual(wizard["template_name"], "口播 · 痛点前置")
|
||||||
|
self.assertEqual(wizard["presentation_format"], "oral")
|
||||||
|
self.assertEqual(wizard["video_structure"], "pain")
|
||||||
|
self.assertEqual(wizard["persona"], "urban")
|
||||||
|
self.assertEqual(wizard["total_duration"], 30)
|
||||||
|
self.assertIn("第 4 镜 · 5s · CTA", wizard["template_outline"])
|
||||||
|
# 新项目仍走完整流水线初始化,不是「复制旧项目」
|
||||||
|
self.assertEqual(project.stages.count(), 5)
|
||||||
|
self.assertEqual(project.script_versions.count(), 0)
|
||||||
|
self.assertEqual(ScriptTemplate.objects.get(id=template_id).usage_count, 1)
|
||||||
|
|
||||||
|
def test_unknown_or_cross_team_template_id_is_dropped_not_fatal(self):
|
||||||
|
other_user, other_team = _make_team("stranger", "Stranger Team")
|
||||||
|
foreign = ScriptTemplate.objects.create(team=other_team, created_by=other_user, name="别人的套路")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{"name": "借用", "product": str(self.product.id), "metadata": {"wizard": {"template_id": str(foreign.id)}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
|
project = Project.objects.get(id=response.data["id"])
|
||||||
|
self.assertNotIn("template_id", project.metadata["wizard"])
|
||||||
|
self.assertNotIn("template_outline", project.metadata["wizard"])
|
||||||
|
self.assertEqual(ScriptTemplate.objects.get(id=foreign.id).usage_count, 0)
|
||||||
|
|
||||||
|
def test_rename_and_delete_template(self):
|
||||||
|
created = self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||||
|
template_id = created.data["id"]
|
||||||
|
|
||||||
|
renamed = self.client.patch(f"/api/script-templates/{template_id}/", {"name": "套路 A2"}, format="json")
|
||||||
|
self.assertEqual(renamed.status_code, 200, renamed.data)
|
||||||
|
self.assertEqual(ScriptTemplate.objects.get(id=template_id).name, "套路 A2")
|
||||||
|
|
||||||
|
removed = self.client.delete(f"/api/script-templates/{template_id}/")
|
||||||
|
self.assertEqual(removed.status_code, 204)
|
||||||
|
self.assertFalse(ScriptTemplate.objects.filter(id=template_id).exists())
|
||||||
@@ -9,6 +9,7 @@ from apps.assets.models import Asset, AssetFile
|
|||||||
from apps.billing.models import CreditAccount, CreditLedger
|
from apps.billing.models import CreditAccount, CreditLedger
|
||||||
from apps.products.models import Product
|
from apps.products.models import Product
|
||||||
from apps.projects.models import (
|
from apps.projects.models import (
|
||||||
|
ExportJob,
|
||||||
Project,
|
Project,
|
||||||
ProjectStage,
|
ProjectStage,
|
||||||
ScriptSegment,
|
ScriptSegment,
|
||||||
@@ -981,6 +982,176 @@ class ExportClipsTests(TestCase):
|
|||||||
self.assertEqual(res.status_code, 400)
|
self.assertEqual(res.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class MergeFinalVideoTests(TestCase):
|
||||||
|
"""合成成片(submit-export):按当前采用片段重建时间线 · 在跑的任务复用 · 成片地址下发。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="mrg", password="p")
|
||||||
|
self.team = Team.objects.create(name="MrgT", owner=self.user)
|
||||||
|
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||||
|
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
team=self.team, name="项目甲", product=self.product, created_by=self.user,
|
||||||
|
current_stage=ProjectStage.Stage.VIDEO, status=Project.Status.COMPLETED,
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
def _video_asset(self, tag):
|
||||||
|
asset = Asset.objects.create(
|
||||||
|
team=self.team, name=f"clip-{tag}", asset_type="video", source="ai_generated", category="video_clip",
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=asset, object_key=f"{tag}.mp4", bucket="b", content_type="video/mp4",
|
||||||
|
is_primary=True, preview_url=f"http://x/{tag}.mp4",
|
||||||
|
)
|
||||||
|
return asset
|
||||||
|
|
||||||
|
def _segment(self, order, tag):
|
||||||
|
asset = self._video_asset(tag)
|
||||||
|
seg = VideoSegment.objects.create(
|
||||||
|
project=self.project, sort_order=order, target_duration_seconds=10,
|
||||||
|
status=VideoSegment.Status.SUCCEEDED,
|
||||||
|
)
|
||||||
|
version = VideoSegmentVersion.objects.create(video_segment=seg, asset=asset, is_adopted=True)
|
||||||
|
seg.adopted_version = version
|
||||||
|
seg.save(update_fields=["adopted_version"])
|
||||||
|
return seg, asset
|
||||||
|
|
||||||
|
def _submit(self):
|
||||||
|
with patch("apps.projects.views.run_export_job_in_thread") as runner:
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
res = self.client.post(f"/api/projects/{self.project.id}/submit-export/")
|
||||||
|
return res, runner
|
||||||
|
|
||||||
|
def test_submit_builds_timeline_and_keeps_video_stage(self):
|
||||||
|
self._segment(0, "a")
|
||||||
|
self._segment(1, "b")
|
||||||
|
res, runner = self._submit()
|
||||||
|
self.assertEqual(res.status_code, 202)
|
||||||
|
runner.assert_called_once()
|
||||||
|
timeline = Timeline.objects.get(project=self.project)
|
||||||
|
clips = list(timeline.clips.order_by("sort_order"))
|
||||||
|
self.assertEqual(len(clips), 2)
|
||||||
|
self.assertEqual([c.start_ms for c in clips], [0, 10000])
|
||||||
|
# V1 视频是末阶段:合成不能把项目推到被雪藏的第 5 阶段(否则流水线页落到看不见的页面)
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
self.assertEqual(self.project.current_stage, ProjectStage.Stage.VIDEO)
|
||||||
|
|
||||||
|
def test_submit_reuses_running_job(self):
|
||||||
|
self._segment(0, "a")
|
||||||
|
first, _ = self._submit()
|
||||||
|
job_id = first.data["id"]
|
||||||
|
ExportJob.objects.filter(id=job_id).update(status=ExportJob.Status.RUNNING)
|
||||||
|
second, runner = self._submit()
|
||||||
|
self.assertEqual(second.status_code, 202)
|
||||||
|
self.assertEqual(str(second.data["id"]), str(job_id))
|
||||||
|
self.assertEqual(ExportJob.objects.filter(timeline__project=self.project).count(), 1)
|
||||||
|
runner.assert_not_called()
|
||||||
|
|
||||||
|
def test_resubmit_rebuilds_clips_after_segment_rerun(self):
|
||||||
|
seg, _old = self._segment(0, "old")
|
||||||
|
self._submit()
|
||||||
|
# 上一次合成已收工(否则「重新合成」会复用在跑的任务,见 test_submit_reuses_running_job)
|
||||||
|
ExportJob.objects.filter(timeline__project=self.project).update(status=ExportJob.Status.SUCCEEDED)
|
||||||
|
new_asset = self._video_asset("new")
|
||||||
|
version = VideoSegmentVersion.objects.create(video_segment=seg, asset=new_asset, is_adopted=True)
|
||||||
|
seg.adopted_version = version
|
||||||
|
seg.save(update_fields=["adopted_version"])
|
||||||
|
self._submit()
|
||||||
|
clips = list(Timeline.objects.get(project=self.project).clips.all())
|
||||||
|
self.assertEqual([c.asset_id for c in clips], [new_asset.id])
|
||||||
|
|
||||||
|
def test_final_video_url_exposed_after_success(self):
|
||||||
|
self._segment(0, "a")
|
||||||
|
self._submit()
|
||||||
|
timeline = Timeline.objects.get(project=self.project)
|
||||||
|
final = Asset.objects.create(
|
||||||
|
team=self.team, name="final.mp4", asset_type="video", source="exported", category="final_video",
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=final, object_key="final.mp4", bucket="b", content_type="video/mp4",
|
||||||
|
is_primary=True, preview_url="http://x/final.mp4",
|
||||||
|
)
|
||||||
|
job = timeline.export_jobs.order_by("-created_at").first()
|
||||||
|
job.status = "succeeded"
|
||||||
|
job.output_asset = final
|
||||||
|
job.progress = 100
|
||||||
|
job.save(update_fields=["status", "output_asset", "progress"])
|
||||||
|
|
||||||
|
detail = self.client.get(f"/api/projects/{self.project.id}/")
|
||||||
|
self.assertEqual(detail.data["final_video_url"], "http://x/final.mp4")
|
||||||
|
listed = self.client.get("/api/projects/")
|
||||||
|
row = next(p for p in listed.data["results"] if str(p["id"]) == str(self.project.id))
|
||||||
|
self.assertEqual(row["final_video_url"], "http://x/final.mp4")
|
||||||
|
|
||||||
|
def test_final_video_url_empty_before_merge(self):
|
||||||
|
self._segment(0, "a")
|
||||||
|
detail = self.client.get(f"/api/projects/{self.project.id}/")
|
||||||
|
self.assertEqual(detail.data["final_video_url"], "")
|
||||||
|
|
||||||
|
def test_merge_drops_bgm_without_audio_stream(self):
|
||||||
|
"""BGM 文件里没有音频流(上传串了张图 / 演示种子数据)→ 丢掉 BGM 照常拼片,
|
||||||
|
不能让滤镜里的 [n:a] 匹配不到流,把整条合成炸掉。"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from apps.projects.models import BgmTrack
|
||||||
|
from apps.projects.services import export as export_mod
|
||||||
|
|
||||||
|
seg, _asset = self._segment(0, "a")
|
||||||
|
timeline = Timeline.objects.create(project=self.project, name="T", duration_seconds=10)
|
||||||
|
TimelineClip.objects.create(
|
||||||
|
timeline=timeline, asset=seg.adopted_version.asset, sort_order=0, start_ms=0, duration_ms=10000,
|
||||||
|
)
|
||||||
|
fake_bgm = Asset.objects.create(team=self.team, name="BGM", asset_type="audio", source="upload")
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=fake_bgm, object_key="cover.png", bucket="b", content_type="image/png", is_primary=True,
|
||||||
|
)
|
||||||
|
BgmTrack.objects.create(timeline=timeline, asset=fake_bgm, volume=60, start_ms=0)
|
||||||
|
job = ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
commands.append(cmd)
|
||||||
|
# ffprobe:一律回「无音频流 / 无帧率」,模拟素材探测不出音轨
|
||||||
|
if cmd[0] == "ffprobe":
|
||||||
|
return type("P", (), {"returncode": 0, "stdout": b"", "stderr": b""})()
|
||||||
|
(Path(kwargs["cwd"]) / "output.mp4").write_bytes(b"MP4")
|
||||||
|
return type("P", (), {"returncode": 0, "stdout": b"", "stderr": b""})()
|
||||||
|
|
||||||
|
with patch.object(export_mod, "_download_asset_primary_file", lambda asset, path: path.write_bytes(b"X")), \
|
||||||
|
patch.object(export_mod.subprocess, "run", side_effect=fake_run), \
|
||||||
|
patch.object(export_mod, "TosStorage") as Tos:
|
||||||
|
Tos.return_value.upload_fileobj.return_value = type(
|
||||||
|
"S", (), {"object_key": "exports/x.mp4", "bucket": "b", "content_type": "video/mp4", "size_bytes": 3},
|
||||||
|
)()
|
||||||
|
export_mod.run_export_job(str(job.id))
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, ExportJob.Status.SUCCEEDED)
|
||||||
|
ffmpeg_cmd = next(c for c in commands if c[0] == "ffmpeg")
|
||||||
|
self.assertNotIn("-stream_loop", ffmpeg_cmd)
|
||||||
|
self.assertNotIn("[abgm]", ffmpeg_cmd[ffmpeg_cmd.index("-filter_complex") + 1])
|
||||||
|
|
||||||
|
def test_final_video_url_skips_non_video_output(self):
|
||||||
|
"""演示种子数据里有「导出成功但挂了张 PNG 海报」的成片:不能下发,否则播放键打开放不出的图。"""
|
||||||
|
self._segment(0, "a")
|
||||||
|
self._submit()
|
||||||
|
poster = Asset.objects.create(
|
||||||
|
team=self.team, name="成片海报", asset_type="video", source="exported", category="final_video",
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=poster, object_key="poster.png", bucket="b", content_type="image/png",
|
||||||
|
is_primary=True, preview_url="http://x/poster.png",
|
||||||
|
)
|
||||||
|
ExportJob.objects.filter(timeline__project=self.project).update(
|
||||||
|
status=ExportJob.Status.SUCCEEDED, output_asset=poster,
|
||||||
|
)
|
||||||
|
detail = self.client.get(f"/api/projects/{self.project.id}/")
|
||||||
|
self.assertEqual(detail.data["final_video_url"], "")
|
||||||
|
|
||||||
|
|
||||||
class BaseAssetAdoptDeleteTests(TestCase):
|
class BaseAssetAdoptDeleteTests(TestCase):
|
||||||
"""显性采用/未采用 + 删除角色组。"""
|
"""显性采用/未采用 + 删除角色组。"""
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ router = DefaultRouter()
|
|||||||
router.register("", ProjectViewSet, basename="project")
|
router.register("", ProjectViewSet, basename="project")
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ from pathlib import Path
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, F, Prefetch, Q
|
||||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.parsers import FormParser, MultiPartParser
|
from rest_framework.parsers import FormParser, MultiPartParser
|
||||||
from rest_framework.renderers import BaseRenderer
|
from rest_framework.renderers import BaseRenderer
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -18,7 +19,7 @@ from apps.ai.providers import TtsNotConfigured
|
|||||||
from apps.ai.script_agent import (
|
from apps.ai.script_agent import (
|
||||||
SEGMENT_DURATION_MAX,
|
SEGMENT_DURATION_MAX,
|
||||||
SEGMENT_DURATION_MIN,
|
SEGMENT_DURATION_MIN,
|
||||||
coerce_combo,
|
combo_keys,
|
||||||
coerce_total_duration,
|
coerce_total_duration,
|
||||||
stream_script_agent,
|
stream_script_agent,
|
||||||
)
|
)
|
||||||
@@ -54,6 +55,7 @@ from .models import (
|
|||||||
Project,
|
Project,
|
||||||
ProjectStage,
|
ProjectStage,
|
||||||
ScriptSegment,
|
ScriptSegment,
|
||||||
|
ScriptTemplate,
|
||||||
ScriptVersion,
|
ScriptVersion,
|
||||||
StoryboardFrame,
|
StoryboardFrame,
|
||||||
StoryboardShot,
|
StoryboardShot,
|
||||||
@@ -70,13 +72,16 @@ from .serializers import (
|
|||||||
ExportJobSerializer,
|
ExportJobSerializer,
|
||||||
ProjectListSerializer,
|
ProjectListSerializer,
|
||||||
ProjectSerializer,
|
ProjectSerializer,
|
||||||
|
ScriptTemplateSerializer,
|
||||||
ScriptVersionSerializer,
|
ScriptVersionSerializer,
|
||||||
StoryboardVersionSerializer,
|
StoryboardVersionSerializer,
|
||||||
VideoSegmentVersionSerializer,
|
VideoSegmentVersionSerializer,
|
||||||
|
is_playable_video,
|
||||||
)
|
)
|
||||||
from .services.export import run_export_job_in_thread
|
from .services.export import run_export_job_in_thread
|
||||||
from .services.pipeline import STAGE_ORDER
|
from .services.pipeline import STAGE_ORDER
|
||||||
from .services.script_import import ScriptFileError, extract_script_text
|
from .services.script_import import ScriptFileError, extract_script_text
|
||||||
|
from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text
|
||||||
from .tasks import poll_video_segment_task
|
from .tasks import poll_video_segment_task
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -315,8 +320,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
|
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
|
||||||
if self.action == "list":
|
if self.action == "list":
|
||||||
qs = (
|
qs = (
|
||||||
Project.objects.select_related("product", "product__cover_asset")
|
Project.objects.select_related("product", "product__cover_asset", "timeline")
|
||||||
.prefetch_related("product__cover_asset__files")
|
.prefetch_related(
|
||||||
|
"product__cover_asset__files",
|
||||||
|
# 成片地址(final_video_url)只需要「成功的导出任务」,预取到位后列表不再逐项目查库
|
||||||
|
Prefetch(
|
||||||
|
"timeline__export_jobs",
|
||||||
|
queryset=ExportJob.objects.filter(status=ExportJob.Status.SUCCEEDED)
|
||||||
|
.select_related("output_asset")
|
||||||
|
.prefetch_related("output_asset__files")
|
||||||
|
.order_by("-created_at"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
script_version_count=Count("script_versions", distinct=True),
|
script_version_count=Count("script_versions", distinct=True),
|
||||||
video_segment_count=Count("video_segments", distinct=True),
|
video_segment_count=Count("video_segments", distinct=True),
|
||||||
@@ -423,6 +438,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
project = serializer.save(team=self.get_team(), created_by=self.request.user)
|
project = serializer.save(team=self.get_team(), created_by=self.request.user)
|
||||||
|
self._apply_wizard_template(project)
|
||||||
for stage in STAGE_ORDER:
|
for stage in STAGE_ORDER:
|
||||||
ProjectStage.objects.create(project=project, stage=stage)
|
ProjectStage.objects.create(project=project, stage=stage)
|
||||||
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
|
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
|
||||||
@@ -431,6 +447,74 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
|
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_wizard_template(self, project) -> None:
|
||||||
|
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
|
||||||
|
前端只传 template_id,套路参数一律以库里的模板为准,避免前后端两份真相。"""
|
||||||
|
wizard = (project.metadata or {}).get("wizard") or {}
|
||||||
|
template_id = wizard.get("template_id")
|
||||||
|
if not template_id:
|
||||||
|
return
|
||||||
|
template = ScriptTemplate.objects.filter(id=template_id, team=project.team).first()
|
||||||
|
if template is None:
|
||||||
|
# 模板被删/跨团队 → 静默丢弃这个字段,项目照常建,不阻断创建
|
||||||
|
wizard.pop("template_id", None)
|
||||||
|
project.metadata = {**(project.metadata or {}), "wizard": wizard}
|
||||||
|
project.save(update_fields=["metadata", "updated_at"])
|
||||||
|
return
|
||||||
|
wizard.update(
|
||||||
|
{
|
||||||
|
"template_id": str(template.id),
|
||||||
|
"template_name": template.name,
|
||||||
|
"template_outline": render_outline_text({"outline": template.outline, "cta": template.cta}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 套路参数只在模板真有值时覆盖,空值不要把设定卡的智能推荐顶掉。
|
||||||
|
# 旧模板可能存了中文标签(「短剧」),这里归一成 key 再写入 wizard。
|
||||||
|
raw_format = getattr(template, "presentation_format", "") or ""
|
||||||
|
raw_structure = getattr(template, "video_structure", "") or ""
|
||||||
|
if raw_format or raw_structure:
|
||||||
|
fmt, structure = coerce_template_combo(raw_format, raw_structure)
|
||||||
|
wizard["presentation_format"] = fmt
|
||||||
|
wizard["video_structure"] = structure
|
||||||
|
persona = coerce_persona(getattr(template, "persona", "") or "")
|
||||||
|
if persona:
|
||||||
|
wizard["persona"] = persona
|
||||||
|
if template.total_duration:
|
||||||
|
wizard["total_duration"] = template.total_duration
|
||||||
|
project.metadata = {**(project.metadata or {}), "wizard": wizard}
|
||||||
|
project.save(update_fields=["metadata", "updated_at"])
|
||||||
|
ScriptTemplate.objects.filter(id=template.id).update(usage_count=F("usage_count") + 1)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="save-as-template")
|
||||||
|
def save_as_template(self, request, pk=None):
|
||||||
|
"""把当前脚本存成团队套路模板(5.1)。只抽套路不抽文案,见 services/templates.py。"""
|
||||||
|
project = self.get_object()
|
||||||
|
name = str(request.data.get("name") or "").strip()
|
||||||
|
if len(name) < 2:
|
||||||
|
return Response({"detail": "模板名至少 2 个字"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
script_id = request.data.get("script_version_id")
|
||||||
|
scripts = ScriptVersion.objects.filter(project=project).prefetch_related("segments")
|
||||||
|
script = (
|
||||||
|
scripts.filter(id=script_id).first()
|
||||||
|
if script_id
|
||||||
|
else (scripts.filter(is_adopted=True).order_by("-created_at").first() or scripts.order_by("-created_at").first())
|
||||||
|
)
|
||||||
|
if script is None:
|
||||||
|
return Response({"detail": "这个项目还没有脚本,先生成一版再存模板"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if not script.segments.exists():
|
||||||
|
return Response({"detail": "这版脚本没有分镜,存不出套路"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if ScriptTemplate.objects.filter(team=project.team, name=name).exists():
|
||||||
|
return Response({"detail": "模板名已存在,换一个"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
template = ScriptTemplate.objects.create(
|
||||||
|
team=project.team,
|
||||||
|
created_by=request.user,
|
||||||
|
name=name,
|
||||||
|
source_project=project,
|
||||||
|
source_script=script,
|
||||||
|
**build_template_fields(project=project, script=script),
|
||||||
|
)
|
||||||
|
return Response(ScriptTemplateSerializer(template).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
|
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
|
||||||
def script_agent_stream(self, request, pk=None):
|
def script_agent_stream(self, request, pk=None):
|
||||||
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
||||||
@@ -446,7 +530,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
||||||
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
|
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
|
||||||
total_duration = coerce_total_duration(request.data.get("total_duration"))
|
total_duration = coerce_total_duration(request.data.get("total_duration"))
|
||||||
presentation_format, video_structure = coerce_combo(
|
presentation_format, video_structure = combo_keys(
|
||||||
request.data.get("presentation_format"),
|
request.data.get("presentation_format"),
|
||||||
request.data.get("video_structure"),
|
request.data.get("video_structure"),
|
||||||
)
|
)
|
||||||
@@ -1135,6 +1219,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
@action(detail=True, methods=["post"], url_path="submit-export")
|
@action(detail=True, methods=["post"], url_path="submit-export")
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def submit_export(self, request, pk=None):
|
def submit_export(self, request, pk=None):
|
||||||
|
"""把已出片的各场视频用 ffmpeg 拼成一条成片。HTTP 秒回,拼接在后台线程跑,
|
||||||
|
前端轮询 poll-export 取进度 / 成片地址。"""
|
||||||
project = self.get_object()
|
project = self.get_object()
|
||||||
missing_segments = project.video_segments.filter(adopted_version__isnull=True).count()
|
missing_segments = project.video_segments.filter(adopted_version__isnull=True).count()
|
||||||
if missing_segments:
|
if missing_segments:
|
||||||
@@ -1146,6 +1232,27 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
project=project,
|
project=project,
|
||||||
defaults={"name": f"{project.name} Timeline", "duration_seconds": 60},
|
defaults={"name": f"{project.name} Timeline", "duration_seconds": 60},
|
||||||
)
|
)
|
||||||
|
# 清理僵尸任务:后台线程跑导出时若 Pod 被重启/OOM 杀掉,旧任务会永远停在 RUNNING、
|
||||||
|
# 前端无限轮询。新建前把本时间线超过 5 分钟仍未结束的旧任务标失败,避免界面一直转圈。
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
ExportJob.objects.filter(
|
||||||
|
timeline=timeline,
|
||||||
|
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||||
|
updated_at__lt=timezone.now() - timedelta(minutes=5),
|
||||||
|
).update(status=ExportJob.Status.FAILED, error_message="任务中断(服务重启或超时),已自动失败,请重新导出")
|
||||||
|
# 防重复合成:仍在跑的任务直接复用,不再新建 —— 刷新页面 / 连点两次 / 开两个标签页
|
||||||
|
# 都只会有一条 ffmpeg 在跑(拼接吃 CPU,跑两遍纯属浪费,且两条任务会互相覆盖成片)。
|
||||||
|
inflight = ExportJob.objects.filter(
|
||||||
|
timeline=timeline,
|
||||||
|
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||||
|
).order_by("-created_at").first()
|
||||||
|
if inflight is not None:
|
||||||
|
return Response(ExportJobSerializer(inflight).data, status=status.HTTP_202_ACCEPTED)
|
||||||
|
if VIDEO_IS_FINAL_STAGE:
|
||||||
|
# V1 没有剪辑台(第 5 阶段雪藏),时间线纯由后端按「当前采用的片段」生成。
|
||||||
|
# 每次合成都重建:某场重跑/换采用版之后再合成,成片必须跟着换,不能沿用上次的旧片段。
|
||||||
|
timeline.clips.all().delete()
|
||||||
if not timeline.clips.exists():
|
if not timeline.clips.exists():
|
||||||
start_ms = 0
|
start_ms = 0
|
||||||
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
|
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
|
||||||
@@ -1158,19 +1265,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
duration_ms=segment.target_duration_seconds * 1000,
|
duration_ms=segment.target_duration_seconds * 1000,
|
||||||
)
|
)
|
||||||
start_ms += segment.target_duration_seconds * 1000
|
start_ms += segment.target_duration_seconds * 1000
|
||||||
# 清理僵尸任务:后台线程跑导出时若 Pod 被重启/OOM 杀掉,旧任务会永远停在 RUNNING、
|
|
||||||
# 前端无限轮询。新建前把本时间线超过 5 分钟仍未结束的旧任务标失败,避免界面一直转圈。
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
ExportJob.objects.filter(
|
|
||||||
timeline=timeline,
|
|
||||||
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
|
||||||
updated_at__lt=timezone.now() - timedelta(minutes=5),
|
|
||||||
).update(status=ExportJob.Status.FAILED, error_message="任务中断(服务重启或超时),已自动失败,请重新导出")
|
|
||||||
export_job = create_export_job(timeline=timeline, user=request.user)
|
export_job = create_export_job(timeline=timeline, user=request.user)
|
||||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
|
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
|
||||||
stage.status = ProjectStage.Status.RUNNING
|
stage.status = ProjectStage.Status.RUNNING
|
||||||
stage.save(update_fields=["status", "updated_at"])
|
stage.save(update_fields=["status", "updated_at"])
|
||||||
|
if not VIDEO_IS_FINAL_STAGE:
|
||||||
|
# V1 视频是末阶段:一旦把 current_stage 推到 export,流水线页会落到被雪藏的第 5 阶段
|
||||||
|
# (前端 STAGE_STEPS 只有 4 格)→ 用户点完合成就掉进一个看不见的页面。故 V1 留在视频阶段,
|
||||||
|
# 合成进度由 poll-export 内联展示。V2 恢复剪辑台后此处照旧推进。
|
||||||
project.current_stage = ProjectStage.Stage.EXPORT
|
project.current_stage = ProjectStage.Stage.EXPORT
|
||||||
project.status = Project.Status.EXPORTING
|
project.status = Project.Status.EXPORTING
|
||||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||||
@@ -1189,7 +1291,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
|
|
||||||
output_url = ""
|
output_url = ""
|
||||||
output = export_job.output_asset
|
output = export_job.output_asset
|
||||||
if output is not None:
|
# 必须是真能播的视频文件才下发:演示种子数据里有「导出标成功、挂的却是张 PNG 海报」的成片,
|
||||||
|
# 直接下发会让「播放成片」打开一个放不出来的图(与 serializers._final_video_url 同一把闸)。
|
||||||
|
if output is not None and is_playable_video(output):
|
||||||
primary = output.files.filter(is_primary=True).first() or output.files.first()
|
primary = output.files.filter(is_primary=True).first() or output.files.first()
|
||||||
if primary is not None:
|
if primary is not None:
|
||||||
output_url = AssetFileSerializer().get_preview_url(primary)
|
output_url = AssetFileSerializer().get_preview_url(primary)
|
||||||
@@ -1412,3 +1516,19 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
timeline.metadata = metadata
|
timeline.metadata = metadata
|
||||||
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
|
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
|
||||||
return Response(ProjectSerializer(self.get_object()).data)
|
return Response(ProjectSerializer(self.get_object()).data)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptTemplateViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||||
|
"""套路模板库(团队隔离)。建模板走 projects/{id}/save-as-template/,这里只做 列 / 改名 / 删。"""
|
||||||
|
|
||||||
|
queryset = ScriptTemplate.objects.select_related("source_project").all()
|
||||||
|
serializer_class = ScriptTemplateSerializer
|
||||||
|
http_method_names = ["get", "patch", "delete", "head", "options"]
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
name = str(serializer.validated_data.get("name") or "").strip()
|
||||||
|
if len(name) < 2:
|
||||||
|
raise ValidationError({"detail": "模板名至少 2 个字"})
|
||||||
|
if ScriptTemplate.objects.filter(team=self.get_team(), name=name).exclude(id=serializer.instance.id).exists():
|
||||||
|
raise ValidationError({"detail": "模板名已存在,换一个"})
|
||||||
|
serializer.save(name=name)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
Team,
|
Team,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
|
TimelineSavePayload,
|
||||||
User,
|
User,
|
||||||
UserPreference
|
UserPreference
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -127,6 +128,8 @@ export function App() {
|
|||||||
const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来
|
const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来
|
||||||
const [detailRetry, setDetailRetry] = useState(0); // 手动重试计数,变化即重新触发详情 useEffect
|
const [detailRetry, setDetailRetry] = useState(0); // 手动重试计数,变化即重新触发详情 useEffect
|
||||||
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
|
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
|
||||||
|
// 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询
|
||||||
|
const exportingRef = useRef(false);
|
||||||
const [preferences, setPreferences] = useState<UserPreference | null>(null);
|
const [preferences, setPreferences] = useState<UserPreference | null>(null);
|
||||||
const [sessions, setSessions] = useState<LoginSession[]>([]);
|
const [sessions, setSessions] = useState<LoginSession[]>([]);
|
||||||
|
|
||||||
@@ -455,6 +458,44 @@ export function App() {
|
|||||||
if (res) setExportResult(res);
|
if (res) setExportResult(res);
|
||||||
}, [activeProjectId]);
|
}, [activeProjectId]);
|
||||||
|
|
||||||
|
// 合成成片(把各场视频用 ffmpeg 拼成一条):**不走 action()** —— 拼接要几十秒到几分钟,
|
||||||
|
// 占住全局 loading + 单飞锁会把整条流水线按钮全禁掉(期间用户还想播放/重跑单场)。
|
||||||
|
// 进度靠 exportResult 内联展示;后端对在跑的任务会复用,连点不会拼两遍。
|
||||||
|
// payload 给定(V2 剪辑台)则先落盘编辑态,成片即所见;V1 视频阶段直接合成不传。
|
||||||
|
const submitExport = useCallback(async (payload?: TimelineSavePayload) => {
|
||||||
|
const projectId = activeProjectId;
|
||||||
|
if (!projectId || exportingRef.current) return;
|
||||||
|
exportingRef.current = true;
|
||||||
|
setNotice(null);
|
||||||
|
setExportResult({ status: "running", progress: 0, output_asset: null, output_url: "", error_message: "" });
|
||||||
|
try {
|
||||||
|
if (payload) await api.saveTimeline(projectId, payload);
|
||||||
|
await api.submitExport(projectId);
|
||||||
|
// 后端在后台线程跑 ffmpeg;这里轮询 poll-export 直到成片/失败,实时回填进度。
|
||||||
|
// 上限 ~8 分钟:后端 ffmpeg 自身 15 分钟超时,轮询窗口耗尽只是停止盯着,任务仍在跑。
|
||||||
|
for (let i = 0; i < 240; i += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
const res = await api.pollExport(projectId);
|
||||||
|
setExportResult(res);
|
||||||
|
if (res.status === "succeeded") {
|
||||||
|
setNotice({ type: "success", text: "成片已合成,可直接播放" });
|
||||||
|
// 列表页的播放按钮读 final_video_url,合成完要刷一次才拿到
|
||||||
|
void loadData();
|
||||||
|
void refreshProjectDetail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.status === "failed") throw new Error(res.error_message || "合成失败,请重试");
|
||||||
|
}
|
||||||
|
throw new Error("合成用时超出预期,后台可能仍在拼接,稍后回到本页查看");
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({ type: "error", text: error instanceof Error ? error.message : "合成失败" });
|
||||||
|
await refreshExport();
|
||||||
|
} finally {
|
||||||
|
exportingRef.current = false;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeProjectId, refreshExport]);
|
||||||
|
|
||||||
function navigate(next: Page, options: NavigateOptions = {}) {
|
function navigate(next: Page, options: NavigateOptions = {}) {
|
||||||
// 已知为子账号时在发起导航前拦截;与直接访问 URL 的 layout guard 共用同一规则。
|
// 已知为子账号时在发起导航前拦截;与直接访问 URL 的 layout guard 共用同一规则。
|
||||||
if (role && !isOwner && isOwnerOnlyPage(next)) {
|
if (role && !isOwner && isOwnerOnlyPage(next)) {
|
||||||
@@ -1101,23 +1142,7 @@ export function App() {
|
|||||||
onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")}
|
onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")}
|
||||||
onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")}
|
onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")}
|
||||||
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
|
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
|
||||||
onSubmitExport={(payload) =>
|
onSubmitExport={submitExport}
|
||||||
action(async () => {
|
|
||||||
// 导出前先落盘当前编辑态(片段/字幕/转场/BGM),成片即所见
|
|
||||||
if (payload) await api.saveTimeline(pipelineProject.id, payload);
|
|
||||||
await api.submitExport(pipelineProject.id);
|
|
||||||
// 后端在后台线程跑 ffmpeg 拼接,这里轮询 poll-export 直到成片/失败,实时回填进度
|
|
||||||
for (let i = 0; i < 160; i += 1) {
|
|
||||||
const res = await api.pollExport(pipelineProject.id);
|
|
||||||
setExportResult(res);
|
|
||||||
if (res.status === "succeeded") return res;
|
|
||||||
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
||||||
}
|
|
||||||
// 轮询窗口耗尽:如实报超时(后台 ffmpeg 可能仍在跑,进入拼接页会自动回填)
|
|
||||||
throw new Error("导出超时,后台可能仍在拼接,稍后回到本页查看");
|
|
||||||
}, "成片已导出")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
Product,
|
Product,
|
||||||
Project,
|
Project,
|
||||||
RechargeResult,
|
RechargeResult,
|
||||||
|
ScriptTemplate,
|
||||||
ScriptVersion,
|
ScriptVersion,
|
||||||
Team,
|
Team,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
@@ -258,6 +259,7 @@ export const api = {
|
|||||||
createProduct(payload: {
|
createProduct(payload: {
|
||||||
title?: string;
|
title?: string;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
|
business_type?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
target_audience?: string;
|
target_audience?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -342,6 +344,23 @@ export const api = {
|
|||||||
{ method: "POST", body: formData }
|
{ method: "POST", body: formData }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
// ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ──
|
||||||
|
// 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。
|
||||||
|
saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) {
|
||||||
|
return request<ScriptTemplate>(`/api/projects/${projectId}/save-as-template/`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
listScriptTemplates() {
|
||||||
|
return request<Paginated<ScriptTemplate>>("/api/script-templates/");
|
||||||
|
},
|
||||||
|
renameScriptTemplate(id: string, name: string) {
|
||||||
|
return request<ScriptTemplate>(`/api/script-templates/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
|
||||||
|
},
|
||||||
|
deleteScriptTemplate(id: string) {
|
||||||
|
return request<void>(`/api/script-templates/${id}/`, { method: "DELETE" });
|
||||||
|
},
|
||||||
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
|
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
|
||||||
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
|
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
|
||||||
async agentScriptStream(
|
async agentScriptStream(
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import { createPortal } from "react-dom";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useBodyScrollLock, ConfirmModal } from "./overlays";
|
import { useBodyScrollLock, ConfirmModal } from "./overlays";
|
||||||
import type { Product } from "../types";
|
import type { Product } from "../types";
|
||||||
|
import {
|
||||||
|
BUSINESS_TYPES,
|
||||||
|
BUSINESS_TYPE_KEYS,
|
||||||
|
PC_CAT_OPTIONS,
|
||||||
|
catOptionsFor,
|
||||||
|
type BusinessType,
|
||||||
|
} from "../product-business";
|
||||||
import "../product-create-page.css";
|
import "../product-create-page.css";
|
||||||
|
|
||||||
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
export { PC_CAT_OPTIONS };
|
||||||
|
|
||||||
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
|
|
||||||
const PF_MAX = 5;
|
|
||||||
|
|
||||||
export type ProductCreatePayload = {
|
export type ProductCreatePayload = {
|
||||||
title: string;
|
title: string;
|
||||||
|
business_type?: BusinessType;
|
||||||
category: string;
|
category: string;
|
||||||
target_audience?: string;
|
target_audience?: string;
|
||||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||||
@@ -21,6 +26,9 @@ export type ProductCreatePayload = {
|
|||||||
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>;
|
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
|
||||||
|
const PF_MAX = 5;
|
||||||
|
|
||||||
// 抽屉内主图条目:拖入即传 /assets/upload/ 进桶,assetId 是返回的素材 id;url 仅作本地缩略预览
|
// 抽屉内主图条目:拖入即传 /assets/upload/ 进桶,assetId 是返回的素材 id;url 仅作本地缩略预览
|
||||||
type PfImage = { id: string; file: File; url: string; assetId?: string; status: "uploading" | "done" | "error" };
|
type PfImage = { id: string; file: File; url: string; assetId?: string; status: "uploading" | "done" | "error" };
|
||||||
|
|
||||||
@@ -40,7 +48,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
}) {
|
}) {
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [category, setCategory] = useState("");
|
const [businessType, setBusinessType] = useState<BusinessType>("ecommerce");
|
||||||
|
const [category, setCategory] = useState(PC_CAT_OPTIONS[0]);
|
||||||
const [target, setTarget] = useState("");
|
const [target, setTarget] = useState("");
|
||||||
// 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行,
|
// 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行,
|
||||||
// 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。
|
// 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。
|
||||||
@@ -59,6 +68,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
|
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
|
||||||
const imagesRef = useRef<PfImage[]>([]);
|
const imagesRef = useRef<PfImage[]>([]);
|
||||||
imagesRef.current = images;
|
imagesRef.current = images;
|
||||||
|
const isLocal = businessType === "local_life";
|
||||||
|
const activeCatOptions = isLocal ? catOptionsFor(businessType) : (catOptions.length ? catOptions : PC_CAT_OPTIONS);
|
||||||
|
|
||||||
function flash(title: string, sub: string) {
|
function flash(title: string, sub: string) {
|
||||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
@@ -72,7 +83,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setCategory("");
|
setBusinessType("ecommerce");
|
||||||
|
setCategory(PC_CAT_OPTIONS[0]);
|
||||||
setTarget("");
|
setTarget("");
|
||||||
setBullets([]);
|
setBullets([]);
|
||||||
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
||||||
@@ -84,7 +96,13 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
|
|
||||||
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
|
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
|
||||||
function isDirty() {
|
function isDirty() {
|
||||||
return Boolean(title.trim() || target.trim() || bullets.some((b) => b.trim()) || images.length);
|
return Boolean(title.trim() || businessType !== "ecommerce" || target.trim() || bullets.some((b) => b.trim()) || images.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeBusinessType(next: BusinessType) {
|
||||||
|
setBusinessType(next);
|
||||||
|
const nextOpts = catOptionsFor(next);
|
||||||
|
if (!nextOpts.includes(category)) setCategory(nextOpts[0]);
|
||||||
}
|
}
|
||||||
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
|
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
|
||||||
function guardedClose() {
|
function guardedClose() {
|
||||||
@@ -166,7 +184,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
const name = title.trim();
|
const name = title.trim();
|
||||||
if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应
|
if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应
|
||||||
if (images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
|
// 电商必须有主图;本地生活团购是虚拟商品,允许不传图。
|
||||||
|
if (!isLocal && images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
|
||||||
// upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建
|
// upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建
|
||||||
if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; }
|
if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; }
|
||||||
if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; }
|
if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; }
|
||||||
@@ -179,11 +198,16 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
// 图已在桶里,创建时直接带上 cover_asset(主图)+ images(全部),一步落库、不再二次传图
|
// 图已在桶里,创建时直接带上 cover_asset(主图)+ images(全部),一步落库、不再二次传图
|
||||||
const created = (await onCreate({
|
const created = (await onCreate({
|
||||||
title: name,
|
title: name,
|
||||||
category: category || catOptions[0],
|
business_type: businessType,
|
||||||
|
category: category || activeCatOptions[0],
|
||||||
target_audience: target.trim() || undefined,
|
target_audience: target.trim() || undefined,
|
||||||
selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })),
|
selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })),
|
||||||
|
...(images[0]?.assetId
|
||||||
|
? {
|
||||||
cover_asset: images[0].assetId,
|
cover_asset: images[0].assetId,
|
||||||
images: images.map((im, i) => ({ asset: im.assetId as string, sort_order: i, is_primary: i === 0 }))
|
images: images.map((im, i) => ({ asset: im.assetId as string, sort_order: i, is_primary: i === 0 })),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})) || null;
|
})) || null;
|
||||||
if (!created) { flash("商品创建失败", "请稍后重试"); return; } // 不静默:建库失败也给提示
|
if (!created) { flash("商品创建失败", "请稍后重试"); return; } // 不静默:建库失败也给提示
|
||||||
close();
|
close();
|
||||||
@@ -212,21 +236,29 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
{titleError && <div className="pc-err-note">请先填写商品名称</div>}
|
{titleError && <div className="pc-err-note">请先填写商品名称</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">类目<span className="req">*</span></label>
|
||||||
|
<select className="select" value={businessType} onChange={(event) => changeBusinessType(event.target.value as BusinessType)}>
|
||||||
|
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
|
||||||
|
</select>
|
||||||
|
{isLocal && <div className="pc-type-hint mono">// 团购按虚拟商品走主流程,无需实物三视图</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">品类<span className="req">*</span></label>
|
<label className="field-label">品类<span className="req">*</span></label>
|
||||||
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
|
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||||
{catOptions.map((option) => <option key={option}>{option}</option>)}
|
{activeCatOptions.map((option) => <option key={option}>{option}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">目标人群<span className="opt">(选填)</span></label>
|
<label className="field-label">目标人群<span className="opt">(选填)</span></label>
|
||||||
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
|
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder={isLocal ? "例: 附近上班族、带娃家庭、周末聚餐" : "例: 22-32 岁女性、敏感肌、办公室通勤"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">商品主图<span className="req">*</span></label>
|
<label className="field-label">{isLocal ? "门店图 / 套餐海报" : "商品主图"}{isLocal ? <span className="opt">(选填)</span> : <span className="req">*</span>}</label>
|
||||||
<input ref={imgInputRef} type="file" accept="image/*" multiple hidden onChange={pickImage} />
|
<input ref={imgInputRef} type="file" accept="image/*" multiple hidden onChange={pickImage} />
|
||||||
<div className="pf-upload-row">
|
<div className="pf-upload-row">
|
||||||
<div
|
<div
|
||||||
@@ -243,7 +275,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
|
<div className="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
|
||||||
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
|
<div className="uz-d">{isLocal ? "// 店招、套餐海报均可 · 团购无实物可不传" : "// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-example">
|
<div className="pf-example">
|
||||||
<div className="ex-h">示例图</div>
|
<div className="ex-h">示例图</div>
|
||||||
@@ -252,7 +284,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
|
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
|
||||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
|
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
<div className="ex-d">{isLocal ? "有门店或套餐图,后续脚本和出图更准" : "优质的商品图有助于生成更好的素材效果"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-grid">
|
<div className="pf-grid">
|
||||||
@@ -301,7 +333,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
value={bullet}
|
value={bullet}
|
||||||
onChange={(event) => updateBullet(index, event.target.value)}
|
onChange={(event) => updateBullet(index, event.target.value)}
|
||||||
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
|
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
|
||||||
placeholder="例: 玻尿酸双效保湿,4 小时持久水润"
|
placeholder={isLocal ? "例: 双人下午茶套餐,含蛋糕+饮品,周末通用" : "例: 玻尿酸双效保湿,4 小时持久水润"}
|
||||||
/>
|
/>
|
||||||
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
|
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||||
@@ -316,8 +348,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
{showGuide && (
|
{showGuide && (
|
||||||
<div className="pc-guide-note">
|
<div className="pc-guide-note">
|
||||||
<strong>// 建好商品的 3 步</strong><br />
|
<strong>// 建好商品的 3 步</strong><br />
|
||||||
① 填写商品名称 + 品类(必填,用于脚本/素材生成)<br />
|
① 填写商品名称 + 类目(电商/本地生活) + 品类<br />
|
||||||
② 上传清晰主图(800×800 以上),便于 AI 出图更准<br />
|
② 电商须上传清晰主图;本地生活/团购无实物可不传<br />
|
||||||
③ 写 2–4 条核心卖点,越具体生成效果越好
|
③ 写 2–4 条核心卖点,越具体生成效果越好
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -344,6 +344,11 @@
|
|||||||
.asset-card-2.prod-lib-card .prod-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 12px; }
|
.asset-card-2.prod-lib-card .prod-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 12px; }
|
||||||
.asset-card-2.prod-lib-card .prod-date { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
|
.asset-card-2.prod-lib-card .prod-date { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
|
||||||
.asset-card-2.prod-lib-card .prod-action { padding: 10px 12px; border-top: 1px solid var(--border-faint); background: var(--surface); }
|
.asset-card-2.prod-lib-card .prod-action { padding: 10px 12px; border-top: 1px solid var(--border-faint); background: var(--surface); }
|
||||||
|
.asset-card-2.prod-lib-card .prod-action .prod-skip-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.asset-card-2.prod-lib-card .prod-action[hidden] { display: none; }
|
.asset-card-2.prod-lib-card .prod-action[hidden] { display: none; }
|
||||||
.asset-card-2.prod-lib-card .prod-action .btn-aigen {
|
.asset-card-2.prod-lib-card .prod-action .btn-aigen {
|
||||||
width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
@@ -629,6 +634,9 @@
|
|||||||
/* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */
|
/* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */
|
||||||
.setup-card .setup-rec { font-size: 12px; line-height: 1.5; color: var(--black-alpha-40); margin: 0 0 8px 74px; }
|
.setup-card .setup-rec { font-size: 12px; line-height: 1.5; color: var(--black-alpha-40); margin: 0 0 8px 74px; }
|
||||||
.setup-card .setup-rec.warn { color: var(--accent-honey); }
|
.setup-card .setup-rec.warn { color: var(--accent-honey); }
|
||||||
|
/* 5.3 · 套用模板时的预期说明:同套路重出 ≠ 旧片换商品名 */
|
||||||
|
.setup-card .tpl-expect { font-size: 12px; line-height: 1.6; color: var(--black-alpha-56); background: var(--heat-8); border-radius: var(--r-sm); padding: 8px 10px; margin-bottom: 8px; }
|
||||||
|
.setup-card .tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 6px; }
|
||||||
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||||
|
|
||||||
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
||||||
@@ -904,3 +912,11 @@
|
|||||||
.asset-card-del-confirm button { height: 24px; padding: 0 8px; border-radius: var(--r-sm); font-size: 11.5px; border: 1px solid var(--border-faint); cursor: pointer; }
|
.asset-card-del-confirm button { height: 24px; padding: 0 8px; border-radius: var(--r-sm); font-size: 11.5px; border: 1px solid var(--border-faint); cursor: pointer; }
|
||||||
.asset-card-del-confirm .del-yes { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
|
.asset-card-del-confirm .del-yes { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
|
||||||
.asset-card-del-confirm .del-no { background: var(--surface); color: var(--black-alpha-56); }
|
.asset-card-del-confirm .del-no { background: var(--surface); color: var(--black-alpha-56); }
|
||||||
|
|
||||||
|
/* 5.1 存为模板弹窗 · 「会存进模板的内容」摊开清单 + 免责说明 */
|
||||||
|
.tpl-capture { display: flex; flex-direction: column; gap: 1px; background: var(--border-faint); border-radius: var(--r-md); overflow: hidden; box-shadow: inset 0 0 0 1px var(--border-faint); }
|
||||||
|
.tpl-capture .row { display: flex; gap: 12px; padding: 9px 12px; background: var(--background-base); font-size: 12.5px; line-height: 1.6; }
|
||||||
|
.tpl-capture .k { flex: 0 0 64px; color: var(--black-alpha-48); }
|
||||||
|
.tpl-capture .v { flex: 1; color: var(--accent-black); word-break: break-word; }
|
||||||
|
.tpl-note { margin: 10px 0 0; font-size: 12px; line-height: 1.7; color: var(--black-alpha-56); }
|
||||||
|
.tpl-note .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); margin-right: 4px; }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** 商品「类目」= 电商 / 本地生活。跟商品库已有的「品类」(美妆/食品)是两个维度,不能混用。 */
|
||||||
|
|
||||||
|
export const BUSINESS_TYPES = {
|
||||||
|
ecommerce: "电商",
|
||||||
|
local_life: "本地生活",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BusinessType = keyof typeof BUSINESS_TYPES;
|
||||||
|
|
||||||
|
export const BUSINESS_TYPE_KEYS = Object.keys(BUSINESS_TYPES) as BusinessType[];
|
||||||
|
|
||||||
|
/** 电商品类 · 新建抽屉 / 详情编辑共用 */
|
||||||
|
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||||
|
|
||||||
|
/** 本地生活品类 · 团购/到店核销走这条,不跟电商 SKU 混在一个下拉里 */
|
||||||
|
export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐"];
|
||||||
|
|
||||||
|
export function isLocalLife(product?: { business_type?: string } | null): boolean {
|
||||||
|
return product?.business_type === "local_life";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catOptionsFor(businessType: string): string[] {
|
||||||
|
return businessType === "local_life" ? PC_LOCAL_CAT_OPTIONS : PC_CAT_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function businessTypeLabel(value?: string | null): string {
|
||||||
|
if (value === "local_life") return BUSINESS_TYPES.local_life;
|
||||||
|
return BUSINESS_TYPES.ecommerce;
|
||||||
|
}
|
||||||
@@ -305,6 +305,11 @@
|
|||||||
.pc-drawer .form-card .field-label .opt {
|
.pc-drawer .form-card .field-label .opt {
|
||||||
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
|
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
.pc-drawer .pc-type-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
}
|
||||||
.pc-drawer .form-card .input,
|
.pc-drawer .form-card .input,
|
||||||
.pc-drawer .form-card .select {
|
.pc-drawer .form-card .select {
|
||||||
width: 100%; height: 38px;
|
width: 100%; height: 38px;
|
||||||
|
|||||||
@@ -480,6 +480,12 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 1 1 0; min-height: 0;
|
flex: 1 1 0; min-height: 0;
|
||||||
}
|
}
|
||||||
|
.ov-actions .qa-row-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1 1 0; min-height: 0;
|
||||||
|
}
|
||||||
.ov-actions .qa-row-1 {
|
.ov-actions .qa-row-1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 0; min-height: 0;
|
flex: 1 1 0; min-height: 0;
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
.product-body { padding: 14px 14px 12px; flex: 1; }
|
.product-body { padding: 14px 14px 12px; flex: 1; }
|
||||||
.product-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.product-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.product-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 12px; }
|
.product-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 12px; }
|
||||||
|
.product-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
|
||||||
|
.product-meta .product-cat { margin-top: 0; }
|
||||||
|
.product-meta .pill { height: 20px; font-size: 11px; }
|
||||||
.product-date { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
|
.product-date { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
|
||||||
|
|
||||||
.product-footer { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-faint); font-size: 12px; color: #6f6f6f; background: var(--background-base); }
|
.product-footer { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-faint); font-size: 12px; color: #6f6f6f; background: var(--background-base); }
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
.product-grid.list-view .product-body { padding: 0; display: flex; align-items: center; gap: 14px; }
|
.product-grid.list-view .product-body { padding: 0; display: flex; align-items: center; gap: 14px; }
|
||||||
.product-grid.list-view .product-name { font-size: 14px; }
|
.product-grid.list-view .product-name { font-size: 14px; }
|
||||||
.product-grid.list-view .product-cat { margin-top: 0; }
|
.product-grid.list-view .product-cat { margin-top: 0; }
|
||||||
|
.product-grid.list-view .product-meta { margin-top: 0; }
|
||||||
.product-grid.list-view .product-date { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 0; }
|
.product-grid.list-view .product-date { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 0; }
|
||||||
.product-grid.list-view .product-footer { border-top: 0; padding: 0; background: transparent; column-gap: 6px; }
|
.product-grid.list-view .product-footer { border-top: 0; padding: 0; background: transparent; column-gap: 6px; }
|
||||||
/* 列表态卡片左上 checkbox 贴合更紧 */
|
/* 列表态卡片左上 checkbox 贴合更紧 */
|
||||||
|
|||||||
@@ -308,6 +308,13 @@
|
|||||||
.wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; }
|
.wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; }
|
||||||
.wiz-start-bar .btn-start svg { width: 14px; height: 14px; }
|
.wiz-start-bar .btn-start svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* ── 5.2/5.3 套路模板 · 选中后的预期说明块 ── */
|
||||||
|
.tpl-expect { margin-top: 10px; padding: 12px 14px; border-radius: var(--r-md); background: var(--heat-8); box-shadow: inset 0 0 0 1px var(--heat-12); }
|
||||||
|
.tpl-expect .te-head { font-size: 13px; line-height: 1.5; color: var(--accent-black); }
|
||||||
|
.tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 8px; }
|
||||||
|
.tpl-expect .te-outline { margin-top: 8px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.75; color: var(--black-alpha-56); white-space: pre-wrap; }
|
||||||
|
.tpl-expect .te-note { margin-top: 10px; font-size: 12px; line-height: 1.65; color: var(--black-alpha-56); }
|
||||||
|
|
||||||
/* ── 响应式:窄屏单列 ── */
|
/* ── 响应式:窄屏单列 ── */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.pp-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.pp-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { useViewMode } from "../components/use-view-mode";
|
|||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { statusPill } from "./stage-config";
|
import { statusPill } from "./stage-config";
|
||||||
import { productMockCoverUrl } from "./products";
|
import { productMockCoverUrl } from "./products";
|
||||||
|
import { isLocalLife } from "../product-business";
|
||||||
import "../ai-tools-page.css";
|
import "../ai-tools-page.css";
|
||||||
|
|
||||||
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
|
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
|
||||||
@@ -666,6 +667,14 @@ export function ImageWorkbenchPage({
|
|||||||
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
|
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
|
||||||
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
||||||
useEffect(() => { if (mode !== "image" && productId) onProductChange?.(productId); }, [mode, productId, onProductChange]);
|
useEffect(() => { if (mode !== "image" && productId) onProductChange?.(productId); }, [mode, productId, onProductChange]);
|
||||||
|
// 本地生活没有模特上身图:进模特工作台时如果带入的是本地生活商品,改选第一个电商商品。
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "model") return;
|
||||||
|
const current = products.find((item) => item.id === productId);
|
||||||
|
if (!current || !isLocalLife(current)) return;
|
||||||
|
const next = products.find((item) => !isLocalLife(item));
|
||||||
|
setProductId(next?.id || "");
|
||||||
|
}, [mode, productId, products]);
|
||||||
// YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零
|
// YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零
|
||||||
useEffect(() => { if (mode !== "image" && productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [mode, productId, unreadByProduct, onProductViewed]);
|
useEffect(() => { if (mode !== "image" && productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [mode, productId, unreadByProduct, onProductViewed]);
|
||||||
const product = products.find((item) => item.id === productId) || products[0];
|
const product = products.find((item) => item.id === productId) || products[0];
|
||||||
@@ -759,6 +768,7 @@ export function ImageWorkbenchPage({
|
|||||||
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
|
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
|
||||||
const [prodQuery, setProdQuery] = useState("");
|
const [prodQuery, setProdQuery] = useState("");
|
||||||
const visibleProducts = products.filter((item) => {
|
const visibleProducts = products.filter((item) => {
|
||||||
|
if (mode === "model" && isLocalLife(item)) return false;
|
||||||
const q = prodQuery.trim().toLowerCase();
|
const q = prodQuery.trim().toLowerCase();
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return `${item.title} ${item.category || ""}`.toLowerCase().includes(q);
|
return `${item.title} ${item.category || ""}`.toLowerCase().includes(q);
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import { isPublicGenerationError, presentGenerationError } from "../generation-e
|
|||||||
import type { Notice, Page } from "./route-config";
|
import type { Notice, Page } from "./route-config";
|
||||||
import { money, stageOrder, statusPill } from "./stage-config";
|
import { money, stageOrder, statusPill } from "./stage-config";
|
||||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||||
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
|
import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
||||||
import { ModelLibrary } from "../components/model-library";
|
import { ModelLibrary } from "../components/model-library";
|
||||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||||
import { IconKitSvg } from "../components/IconKitSvg";
|
import { IconKitSvg } from "../components/IconKitSvg";
|
||||||
import {
|
import {
|
||||||
allowedStructures,
|
allowedStructures,
|
||||||
clampDuration,
|
clampDuration,
|
||||||
|
coercePresentationFormat,
|
||||||
|
coerceVideoStructure,
|
||||||
DURATION_OPTIONS,
|
DURATION_OPTIONS,
|
||||||
durationWarning,
|
durationWarning,
|
||||||
isForbidden,
|
isForbidden,
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
type PresentationFormat,
|
type PresentationFormat,
|
||||||
type VideoStructure,
|
type VideoStructure,
|
||||||
} from "../script-setup";
|
} from "../script-setup";
|
||||||
|
import { isLocalLife } from "../product-business";
|
||||||
|
|
||||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||||
@@ -50,6 +53,15 @@ const VO_VOICES = [
|
|||||||
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
|
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
|
||||||
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
|
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
|
||||||
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
|
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
|
||||||
|
const PERSONA_KEY_BY_LABEL: Record<string, string> = {
|
||||||
|
...Object.fromEntries(Object.entries(WIZ_PERSONA_LABEL).map(([key, label]) => [label, key])),
|
||||||
|
专业测评: "reviewer",
|
||||||
|
};
|
||||||
|
function coercePersona(value: unknown, fallback = "urban"): string {
|
||||||
|
if (typeof value !== "string" || !value) return fallback;
|
||||||
|
if (WIZ_PERSONA_LABEL[value]) return value;
|
||||||
|
return PERSONA_KEY_BY_LABEL[value] || fallback;
|
||||||
|
}
|
||||||
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
|
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
|
||||||
@@ -1276,12 +1288,12 @@ export function PipelinePage(props: {
|
|||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
|
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
|
||||||
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
|
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
|
||||||
(wizard?.presentation_format as PresentationFormat) || recommended.format
|
coercePresentationFormat(wizard?.presentation_format, recommended.format)
|
||||||
);
|
);
|
||||||
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
|
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
|
||||||
(wizard?.video_structure as VideoStructure) || recommended.structure
|
coerceVideoStructure(wizard?.video_structure, recommended.structure)
|
||||||
);
|
);
|
||||||
const [setupPersona, setSetupPersona] = useState<string>(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
|
const [setupPersona, setSetupPersona] = useState<string>(coercePersona(wizard?.persona, SETUP_PERSONA_KEYS[0] || "urban"));
|
||||||
const [setupDuration, setSetupDuration] = useState<number>(() => {
|
const [setupDuration, setSetupDuration] = useState<number>(() => {
|
||||||
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
|
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
|
||||||
// 兼容一期向导存的字符串档位("0-30" → 30)
|
// 兼容一期向导存的字符串档位("0-30" → 30)
|
||||||
@@ -1299,6 +1311,36 @@ export function PipelinePage(props: {
|
|||||||
setSetupStructure(recommended.structure);
|
setSetupStructure(recommended.structure);
|
||||||
if (!wizardHasDuration) setSetupDuration(recommended.duration);
|
if (!wizardHasDuration) setSetupDuration(recommended.duration);
|
||||||
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
|
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
|
||||||
|
|
||||||
|
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
|
||||||
|
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
|
||||||
|
const templateOutline = typeof wizard?.template_outline === "string" ? wizard.template_outline : "";
|
||||||
|
|
||||||
|
// ── 5.1 存为模板 ── 把这一版脚本的套路存进团队模板库,换商品重开项目时套用
|
||||||
|
const [tplOpen, setTplOpen] = useState(false);
|
||||||
|
const [tplName, setTplName] = useState("");
|
||||||
|
const [tplSaving, setTplSaving] = useState(false);
|
||||||
|
function openSaveTemplate() {
|
||||||
|
const formatLabel = PRESENTATION_FORMATS[setupFormat];
|
||||||
|
const structureLabel = VIDEO_STRUCTURES[setupStructure];
|
||||||
|
setTplName(`${formatLabel} · ${structureLabel} · ${shots.length} 镜`);
|
||||||
|
setTplOpen(true);
|
||||||
|
}
|
||||||
|
async function saveTemplate() {
|
||||||
|
const name = tplName.trim();
|
||||||
|
if (name.length < 2 || tplSaving || !currentScript) return;
|
||||||
|
setTplSaving(true);
|
||||||
|
try {
|
||||||
|
const tpl = await api.saveProjectAsTemplate(project.id, { name, script_version_id: currentScript.id });
|
||||||
|
setTplOpen(false);
|
||||||
|
onNotify?.("success", `已存为套路模板「${tpl.name}」,新建项目时可选它换商品重跑`);
|
||||||
|
} catch (error) {
|
||||||
|
onNotify?.("error", error instanceof Error ? error.message : "存模板失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setTplSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
|
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
|
||||||
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
|
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
|
||||||
function pickFormat(next: PresentationFormat) {
|
function pickFormat(next: PresentationFormat) {
|
||||||
@@ -1595,18 +1637,21 @@ export function PipelinePage(props: {
|
|||||||
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
|
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
|
||||||
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
|
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
|
||||||
async function runScriptWithSetup() {
|
async function runScriptWithSetup() {
|
||||||
const formatLabel = PRESENTATION_FORMATS[setupFormat];
|
const format = coercePresentationFormat(setupFormat);
|
||||||
const structureLabel = VIDEO_STRUCTURES[setupStructure];
|
const structure = coerceVideoStructure(setupStructure);
|
||||||
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
const persona = coercePersona(setupPersona);
|
||||||
|
const formatLabel = PRESENTATION_FORMATS[format];
|
||||||
|
const structureLabel = VIDEO_STRUCTURES[structure];
|
||||||
|
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
|
||||||
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
||||||
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
|
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
|
||||||
await onSaveProjectMeta?.({
|
await onSaveProjectMeta?.({
|
||||||
wizard: {
|
wizard: {
|
||||||
...(project.metadata?.wizard ?? {}),
|
...(project.metadata?.wizard ?? {}),
|
||||||
presentation_format: setupFormat,
|
presentation_format: format,
|
||||||
video_structure: setupStructure,
|
video_structure: structure,
|
||||||
total_duration: setupDuration,
|
total_duration: setupDuration,
|
||||||
persona: setupPersona,
|
persona,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
@@ -1645,9 +1690,16 @@ export function PipelinePage(props: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
|
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
|
||||||
|
// 5.2 换商品重跑:新建项目时选了套路模板 → 把模板的镜序/节奏/转化写法并进 prompt,
|
||||||
|
// 但明确要求文案按当前商品重写(模板里不含旧文案,转化写法可能带旧品牌词,这里再挡一道)。
|
||||||
|
const tplLine = templateOutline
|
||||||
|
? `\n请照这个套路重出一版:\n${templateOutline}\n`
|
||||||
|
+ `镜数、每镜作用与时长按上面来,文案全部按我当前的商品重新写,`
|
||||||
|
+ `参考写法里出现的旧商品、品牌、价格一律不要保留。`
|
||||||
|
: "";
|
||||||
await runScriptGeneration(
|
await runScriptGeneration(
|
||||||
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`,
|
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放${tplLine}`,
|
||||||
`${sourceLabel}:${combo}`,
|
templateName ? `套用模板「${templateName}」:${combo}` : `${sourceLabel}:${combo}`,
|
||||||
"ai",
|
"ai",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2473,13 +2525,22 @@ export function PipelinePage(props: {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [activeVideoCount, onPollVideosQuiet]);
|
}, [activeVideoCount, onPollVideosQuiet]);
|
||||||
|
|
||||||
// Stage 5:进入拼接页时回填已有导出成片(若此前导过)
|
// 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewStage !== 5) return;
|
if (viewStage !== 4 && viewStage !== 5) return;
|
||||||
onRefreshExport();
|
onRefreshExport();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [viewStage]);
|
}, [viewStage]);
|
||||||
|
|
||||||
|
// 合成中:静默轮进度。合成本身由发起方(App.submitExport)在轮,这里是为「合成中刷新了页面」
|
||||||
|
// 兜底 —— 否则发起方的轮询循环随刷新丢掉,界面会卡在旧百分比、按钮一直禁用。
|
||||||
|
const exportRunning = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!exportRunning || (viewStage !== 4 && viewStage !== 5)) return;
|
||||||
|
const timer = setInterval(() => onRefreshExport(), 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [exportRunning, viewStage, onRefreshExport]);
|
||||||
|
|
||||||
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
|
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
|
||||||
async function confirmScript() {
|
async function confirmScript() {
|
||||||
if (currentScript && !scriptAdopted) {
|
if (currentScript && !scriptAdopted) {
|
||||||
@@ -2645,6 +2706,7 @@ export function PipelinePage(props: {
|
|||||||
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
|
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
|
||||||
const productRecord = products.find((item) => item.id === project.product);
|
const productRecord = products.find((item) => item.id === project.product);
|
||||||
const productName = productRecord?.title || "透真补水面膜";
|
const productName = productRecord?.title || "透真补水面膜";
|
||||||
|
const isLocalProduct = isLocalLife(productRecord);
|
||||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
||||||
|
|
||||||
// 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。
|
// 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。
|
||||||
@@ -2795,21 +2857,14 @@ export function PipelinePage(props: {
|
|||||||
<div className="script-brief-summary" aria-label="当前创作方向">
|
<div className="script-brief-summary" aria-label="当前创作方向">
|
||||||
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
|
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
|
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">形式</span><span className="v" id="brief-format">{(() => {
|
<span className="pill neutral script-brief-pill"><span className="k">形式</span><span className="v" id="brief-format">{wizard?.presentation_format ? PRESENTATION_FORMATS[coercePresentationFormat(wizard.presentation_format)] : "待确认"}</span></span>
|
||||||
const k = wizard?.presentation_format as PresentationFormat | undefined;
|
<span className="pill neutral script-brief-pill"><span className="k">结构</span><span className="v" id="brief-structure">{wizard?.video_structure ? VIDEO_STRUCTURES[coerceVideoStructure(wizard.video_structure)] : "待确认"}</span></span>
|
||||||
// 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套
|
|
||||||
return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认";
|
|
||||||
})()}</span></span>
|
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">结构</span><span className="v" id="brief-structure">{(() => {
|
|
||||||
const k = wizard?.video_structure as VideoStructure | undefined;
|
|
||||||
return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认";
|
|
||||||
})()}</span></span>
|
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">时长</span><span className="v" id="brief-duration">{(() => {
|
<span className="pill neutral script-brief-pill"><span className="k">时长</span><span className="v" id="brief-duration">{(() => {
|
||||||
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
|
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
|
||||||
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
||||||
return `${scripted || setupDuration} 秒`;
|
return `${scripted || setupDuration} 秒`;
|
||||||
})()}</span></span>
|
})()}</span></span>
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
|
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{wizard?.persona ? (WIZ_PERSONA_LABEL[coercePersona(wizard.persona)] || wizard.persona) : "待确认"}</span></span>
|
||||||
</div>
|
</div>
|
||||||
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
|
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
|
||||||
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
|
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
|
||||||
@@ -2990,7 +3045,14 @@ export function PipelinePage(props: {
|
|||||||
{setupOpen && (
|
{setupOpen && (
|
||||||
<div className="msg ai">
|
<div className="msg ai">
|
||||||
<div className="bubble setup-card">
|
<div className="bubble setup-card">
|
||||||
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : templateName ? `已套用模板「${templateName}」,套路参数已填好。` : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
||||||
|
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」,免得用户以为是复制粘贴 */}
|
||||||
|
{templateOutline && setupSource === "ai" && (
|
||||||
|
<div className="tpl-expect">
|
||||||
|
<span className="mono">// 同套路重出</span>
|
||||||
|
沿用模板的镜数、每镜作用和节奏,文案、画面、模特与故事板都会按{setupProduct?.title || "当前商品"}重新生成 —— 不是把上一条片子换个商品名。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<label className="setup-field">
|
<label className="setup-field">
|
||||||
<span className="sf-k">表现形式</span>
|
<span className="sf-k">表现形式</span>
|
||||||
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
|
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
|
||||||
@@ -3134,6 +3196,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="stage-foot">
|
<div className="stage-foot">
|
||||||
<div className="info"><span className="mono">[ 脚本生成 {pts(10)} 积分/次 · 失败不扣 ]</span></div>
|
<div className="info"><span className="mono">[ 脚本生成 {pts(10)} 积分/次 · 失败不扣 ]</span></div>
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
|
<button className="btn" type="button" disabled={loading || !currentScript} onClick={openSaveTemplate}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg> 存为模板</button>
|
||||||
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
|
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
|
||||||
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3150,7 +3213,7 @@ export function PipelinePage(props: {
|
|||||||
const adoptedTriAsset = productGroup?.adopted_asset || "";
|
const adoptedTriAsset = productGroup?.adopted_asset || "";
|
||||||
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
||||||
const hasTriView = productVersions.length > 0;
|
const hasTriView = productVersions.length > 0;
|
||||||
const triPanelShow = triPanelOpen || hasTriView || triGenerating;
|
const triPanelShow = isLocalProduct ? hasTriView : (triPanelOpen || hasTriView || triGenerating);
|
||||||
// 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空),
|
// 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空),
|
||||||
// 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。
|
// 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。
|
||||||
const productAssetUrl =
|
const productAssetUrl =
|
||||||
@@ -3227,7 +3290,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="prod-row">
|
<div className="prod-row">
|
||||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={adoptedTriAsset || "prod-main"} id="asset-prod-card">
|
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={adoptedTriAsset || "prod-main"} id="asset-prod-card">
|
||||||
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
|
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
|
||||||
{!hasTriView && (
|
{!isLocalProduct && !hasTriView && (
|
||||||
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
||||||
<span className="ico" aria-hidden="true"></span>
|
<span className="ico" aria-hidden="true"></span>
|
||||||
<span className="lbl-mono">缺三视图</span>
|
<span className="lbl-mono">缺三视图</span>
|
||||||
@@ -3245,16 +3308,19 @@ export function PipelinePage(props: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="prod-body">
|
<div className="prod-body">
|
||||||
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
|
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
|
||||||
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
|
<div className="prod-cat">{isLocalProduct ? "本地生活" : ""}{isLocalProduct && productRecord?.category ? " · " : ""}{productRecord?.category || (isLocalProduct ? "" : "未分类")}</div>
|
||||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prod-action" id="asset-prod-action">
|
<div className="prod-action" id="asset-prod-action">
|
||||||
{/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
|
{isLocalProduct ? (
|
||||||
|
<div className="mono prod-skip-note">// 本地生活无需商品三视图,可直接进入下一步</div>
|
||||||
|
) : (
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
|
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
|
||||||
@@ -3691,6 +3757,12 @@ export function PipelinePage(props: {
|
|||||||
: activeVideoCount > 0
|
: activeVideoCount > 0
|
||||||
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
|
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
|
||||||
: "待生成";
|
: "待生成";
|
||||||
|
// 合成成片(ffmpeg 按场次顺序拼成一条):全部场次出片才能合成。
|
||||||
|
// 命名避开本作用域已有的 exporting/exportErr(那是「片段打包 zip 下载」)。
|
||||||
|
const allSegDone = segments.length > 0 && segDone === segments.length;
|
||||||
|
const mergedUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
|
||||||
|
const merging = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||||
|
const mergeProgress = exportResult?.progress ?? 0;
|
||||||
return (
|
return (
|
||||||
<section className="stage active" data-stage-pane="4">
|
<section className="stage active" data-stage-pane="4">
|
||||||
<div className="queue-bar">
|
<div className="queue-bar">
|
||||||
@@ -3756,13 +3828,36 @@ export function PipelinePage(props: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="stage-foot">
|
<div className="stage-foot">
|
||||||
<div className="info"><span className="mono">[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</span></div>
|
<div className="info">
|
||||||
|
<span className="mono">[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</span>
|
||||||
|
{merging && <span className="mono" style={{ marginLeft: "10px", color: "var(--heat)" }}>// 合成中 {mergeProgress}%</span>}
|
||||||
|
{exportResult?.status === "failed" && (
|
||||||
|
<span className="mono" style={{ marginLeft: "10px", color: "var(--accent-crimson)" }}>// 合成失败:{exportResult.error_message || "请重试"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
|
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
|
||||||
{/* V1 雪藏拼接导出:隐藏「进入拼接」入口(代码保留,V2 恢复 false→true) */}
|
{/* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */}
|
||||||
{false && (
|
{mergedUrl && (
|
||||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>确认视频,进入拼接 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
<>
|
||||||
|
<button className="btn" type="button" onClick={() => setPreview({ src: mergedUrl, kind: "video", name: `${project.name} · 成片` })}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M6 4l13 8-13 8z" /></svg> 播放成片
|
||||||
|
</button>
|
||||||
|
<a className="btn" href={mergedUrl} target="_blank" rel="noreferrer" download>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg> 下载成片
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-lg"
|
||||||
|
type="button"
|
||||||
|
disabled={!allSegDone || merging}
|
||||||
|
title={allSegDone ? "把各场视频按顺序拼成一条完整视频(不扣积分)" : "所有场次都出片后才能合成"}
|
||||||
|
onClick={() => onSubmitExport()}
|
||||||
|
>
|
||||||
|
{merging ? <><span className="spinner btn-spin" aria-hidden="true" />合成中… {mergeProgress}%</> : mergedUrl ? "重新合成" : "合成完整视频"}
|
||||||
|
{!merging && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -4552,6 +4647,35 @@ export function PipelinePage(props: {
|
|||||||
onRefresh={() => void onRefreshProject()}
|
onRefresh={() => void onRefreshProject()}
|
||||||
/>
|
/>
|
||||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||||
|
|
||||||
|
{/* 5.1 存为模板 · 先把「会被存进去的」摊开给用户看,再让他起名,避免以为连文案一起存了 */}
|
||||||
|
<TeamModal
|
||||||
|
open={tplOpen}
|
||||||
|
title="存为套路模板"
|
||||||
|
subtitle="// SAVE TEMPLATE"
|
||||||
|
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg>}
|
||||||
|
close={() => setTplOpen(false)}
|
||||||
|
footer={<button className="btn btn-primary" type="button" disabled={tplName.trim().length < 2 || tplSaving} onClick={() => void saveTemplate()}>{tplSaving ? "保存中…" : "保存模板"}</button>}
|
||||||
|
>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">模板名称<span className="req">*</span></label>
|
||||||
|
<input className="input" value={tplName} maxLength={40} onChange={(event) => setTplName(event.target.value)} placeholder="例如:口播 · 痛点前置 · 5 镜" />
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="field-label">会存进模板的内容</label>
|
||||||
|
<div className="tpl-capture">
|
||||||
|
<div className="row"><span className="k">带货套路</span><span className="v">{PRESENTATION_FORMATS[setupFormat]} · {VIDEO_STRUCTURES[setupStructure]}</span></div>
|
||||||
|
<div className="row"><span className="k">脚本结构</span><span className="v">{shots.map((s) => s.role || "叙述").join(" → ") || "—"}</span></div>
|
||||||
|
<div className="row"><span className="k">镜头节奏</span><span className="v">{shots.length} 镜 · {shots.map((s) => `${s.duration_seconds}s`).join(" / ") || "—"}</span></div>
|
||||||
|
<div className="row"><span className="k">人物设定</span><span className="v">{WIZ_PERSONA_LABEL[setupPersona] || setupPersona}</span></div>
|
||||||
|
<div className="row"><span className="k">转化方式</span><span className="v">{(shots.find((s) => s.role === "CTA") || shots[shots.length - 1])?.narration || "—"}</span></div>
|
||||||
|
</div>
|
||||||
|
<p className="tpl-note">
|
||||||
|
<span className="mono">// NOTE</span> 模板只存上面这套「怎么讲」。这一版脚本里针对
|
||||||
|
{" "}{setupProduct?.title || "当前商品"}{" "}写的口播文案不会存进去 —— 换商品套用时由 AI 按新商品重写。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TeamModal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { ArrowLeft, Trash2, X } from "lucide-react";
|
|||||||
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
|
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
|
||||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||||
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
|
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
|
||||||
|
import {
|
||||||
|
BUSINESS_TYPES,
|
||||||
|
BUSINESS_TYPE_KEYS,
|
||||||
|
catOptionsFor,
|
||||||
|
isLocalLife,
|
||||||
|
type BusinessType,
|
||||||
|
} from "../product-business";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
import { SkeletonGrid } from "../components/loading";
|
import { SkeletonGrid } from "../components/loading";
|
||||||
import { useViewMode } from "../components/use-view-mode";
|
import { useViewMode } from "../components/use-view-mode";
|
||||||
@@ -112,9 +119,10 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
|||||||
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
|
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
|
||||||
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
|
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
|
||||||
const [createdName, setCreatedName] = useState<string | null>(null);
|
const [createdName, setCreatedName] = useState<string | null>(null);
|
||||||
const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
|
const [openChip, setOpenChip] = useState<"" | "cat" | "date" | "type">("");
|
||||||
// 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
|
// 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
|
||||||
const [catFilter, setCatFilter] = useState<Set<string>>(new Set());
|
const [catFilter, setCatFilter] = useState<Set<string>>(new Set());
|
||||||
|
const [typeFilter, setTypeFilter] = useState<"all" | BusinessType>("all");
|
||||||
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all");
|
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all");
|
||||||
// 视图切换:网格 / 列表(.view-tog 黑激活;列表态卡片横排)
|
// 视图切换:网格 / 列表(.view-tog 黑激活;列表态卡片横排)
|
||||||
// ZWQ#row22:持久化 → 切走再切回商品库仍保持用户上次选的网格/列表
|
// ZWQ#row22:持久化 → 切走再切回商品库仍保持用户上次选的网格/列表
|
||||||
@@ -159,17 +167,18 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
|||||||
if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除
|
if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除
|
||||||
const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase());
|
const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase());
|
||||||
const matchCat = catFilter.size === 0 || (product.category ? catFilter.has(product.category) : false);
|
const matchCat = catFilter.size === 0 || (product.category ? catFilter.has(product.category) : false);
|
||||||
|
const matchType = typeFilter === "all" || (product.business_type || "ecommerce") === typeFilter;
|
||||||
let matchDate = true;
|
let matchDate = true;
|
||||||
if (dateFilter !== "all" && product.created_at) {
|
if (dateFilter !== "all" && product.created_at) {
|
||||||
const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000;
|
const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000;
|
||||||
matchDate = days <= Number(dateFilter);
|
matchDate = days <= Number(dateFilter);
|
||||||
}
|
}
|
||||||
return matchQuery && matchCat && matchDate;
|
return matchQuery && matchCat && matchType && matchDate;
|
||||||
});
|
});
|
||||||
// 分页:每页 10 个,搜索/筛选变化回第 1 页
|
// 分页:每页 10 个,搜索/筛选变化回第 1 页
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const catFilterKey = Array.from(catFilter).sort().join("|");
|
const catFilterKey = Array.from(catFilter).sort().join("|");
|
||||||
useEffect(() => { setPage(1); }, [query, catFilterKey, dateFilter]);
|
useEffect(() => { setPage(1); }, [query, catFilterKey, dateFilter, typeFilter]);
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
|
||||||
const curPage = Math.min(page, totalPages);
|
const curPage = Math.min(page, totalPages);
|
||||||
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
|
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
|
||||||
@@ -199,6 +208,23 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||||
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
|
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
|
||||||
|
<button className={`chip${typeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
||||||
|
<span className="chip-label">{typeFilter === "all" ? "类目" : BUSINESS_TYPES[typeFilter]}</span>
|
||||||
|
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||||
|
</button>
|
||||||
|
<div className="chip-menu">
|
||||||
|
<div className={`mi${typeFilter === "all" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter("all"); setOpenChip(""); }}>
|
||||||
|
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类目
|
||||||
|
</div>
|
||||||
|
<div className="mi-sep" />
|
||||||
|
{BUSINESS_TYPE_KEYS.map((key) => (
|
||||||
|
<div className={`mi${typeFilter === key ? " selected" : ""}`} key={key} role="button" tabIndex={0} onClick={() => { setTypeFilter(key); setOpenChip(""); }}>
|
||||||
|
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{BUSINESS_TYPES[key]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
|
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
|
||||||
<button className={`chip${catFilter.size ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
|
<button className={`chip${catFilter.size ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
|
||||||
<span className="chip-label">商品分类</span>
|
<span className="chip-label">商品分类</span>
|
||||||
@@ -231,8 +257,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(query || catFilter.size > 0 || dateFilter !== "all") && (
|
{(query || catFilter.size > 0 || dateFilter !== "all" || typeFilter !== "all") && (
|
||||||
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setOpenChip(""); }}>
|
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setTypeFilter("all"); setOpenChip(""); }}>
|
||||||
<X size={13} /> 清空筛选
|
<X size={13} /> 清空筛选
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -335,7 +361,7 @@ export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, on
|
|||||||
// 缺三视图:采用三视图后端会把它落成 product.cover_asset(见 App.tsx onAdoptTriView)。
|
// 缺三视图:采用三视图后端会把它落成 product.cover_asset(见 App.tsx onAdoptTriView)。
|
||||||
// 列表数据里无法逐条反查 tri_view 素材,只能用真实字段:既无采用封面(cover_asset)、又无上传主图(images)
|
// 列表数据里无法逐条反查 tri_view 素材,只能用真实字段:既无采用封面(cover_asset)、又无上传主图(images)
|
||||||
// → 判定该商品尚未补三视图(对齐 V1「新建商品卡显缺三视图」);一旦有封面/图则判不准,不显示(别瞎标)。
|
// → 判定该商品尚未补三视图(对齐 V1「新建商品卡显缺三视图」);一旦有封面/图则判不准,不显示(别瞎标)。
|
||||||
const missingTri = !product.cover_asset && assetCount === 0;
|
const missingTri = !isLocalLife(product) && !product.cover_asset && assetCount === 0;
|
||||||
return (
|
return (
|
||||||
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
|
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
|
||||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
|
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||||
@@ -363,7 +389,10 @@ export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, on
|
|||||||
)}
|
)}
|
||||||
<div className="product-body">
|
<div className="product-body">
|
||||||
<div className="product-name">{product.title}</div>
|
<div className="product-name">{product.title}</div>
|
||||||
|
<div className="product-meta">
|
||||||
|
{isLocalLife(product) && <span className="pill neutral">本地生活</span>}
|
||||||
<div className="product-cat">{product.category || "未分类"}</div>
|
<div className="product-cat">{product.category || "未分类"}</div>
|
||||||
|
</div>
|
||||||
<div className="product-date">{(product.created_at || "").slice(0, 10)} 创建</div>
|
<div className="product-date">{(product.created_at || "").slice(0, 10)} 创建</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="product-footer">
|
<div className="product-footer">
|
||||||
@@ -559,7 +588,10 @@ function pdBaseStatus(asset: Asset): PdAssetStatus {
|
|||||||
return "pending";
|
return "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
function pdCatChoices(businessType: string, current: string): string[] {
|
||||||
|
const extra = businessType === "local_life" ? [] : ["美妆个护 / 精华液"];
|
||||||
|
return Array.from(new Set([...catOptionsFor(businessType), ...extra, ...(current ? [current] : [])]));
|
||||||
|
}
|
||||||
|
|
||||||
// 取一个 Asset 的预览图(优先主文件,其次首文件)
|
// 取一个 Asset 的预览图(优先主文件,其次首文件)
|
||||||
function pdAssetPreview(asset?: Asset): string {
|
function pdAssetPreview(asset?: Asset): string {
|
||||||
@@ -849,11 +881,14 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
// 真实字段 · 只用商品真实数据,缺省留空(不再注入设计稿假数据,避免"没填的卖点/人群凭空冒出来")
|
// 真实字段 · 只用商品真实数据,缺省留空(不再注入设计稿假数据,避免"没填的卖点/人群凭空冒出来")
|
||||||
const realName = product.title || "未命名商品";
|
const realName = product.title || "未命名商品";
|
||||||
const realCat = product.category || "";
|
const realCat = product.category || "";
|
||||||
|
const realType = (product.business_type === "local_life" ? "local_life" : "ecommerce") as BusinessType;
|
||||||
|
const isLocal = realType === "local_life";
|
||||||
const realTarget = product.target_audience || "";
|
const realTarget = product.target_audience || "";
|
||||||
const realBullets = product.selling_points.map((point) => point.title);
|
const realBullets = product.selling_points.map((point) => point.title);
|
||||||
|
|
||||||
const [name, setName] = useState(realName);
|
const [name, setName] = useState(realName);
|
||||||
const [cat, setCat] = useState(realCat);
|
const [cat, setCat] = useState(realCat);
|
||||||
|
const [bizType, setBizType] = useState<BusinessType>(realType);
|
||||||
const [target, setTarget] = useState(realTarget);
|
const [target, setTarget] = useState(realTarget);
|
||||||
const [points, setPoints] = useState<string[]>(realBullets);
|
const [points, setPoints] = useState<string[]>(realBullets);
|
||||||
|
|
||||||
@@ -872,6 +907,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
function save() {
|
function save() {
|
||||||
void onUpdate({
|
void onUpdate({
|
||||||
title: name,
|
title: name,
|
||||||
|
business_type: bizType,
|
||||||
category: cat,
|
category: cat,
|
||||||
target_audience: target,
|
target_audience: target,
|
||||||
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id;
|
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id;
|
||||||
@@ -884,6 +920,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
function cancel() {
|
function cancel() {
|
||||||
setName(realName);
|
setName(realName);
|
||||||
setCat(realCat);
|
setCat(realCat);
|
||||||
|
setBizType(realType);
|
||||||
setTarget(realTarget);
|
setTarget(realTarget);
|
||||||
setPoints(realBullets);
|
setPoints(realBullets);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -905,6 +942,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
<div className="ov-h">
|
<div className="ov-h">
|
||||||
<span className="ti">商品信息</span>
|
<span className="ti">商品信息</span>
|
||||||
{/* AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) */}
|
{/* AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) */}
|
||||||
|
{!isLocal && (
|
||||||
<div className="ov-tri-wrap">
|
<div className="ov-tri-wrap">
|
||||||
{/* icon 规范化:用「立方体三视」线性图标,替代原星星簇,更贴合三视图语义 */}
|
{/* icon 规范化:用「立方体三视」线性图标,替代原星星簇,更贴合三视图语义 */}
|
||||||
<button className={`ov-edit ov-tri-trigger${triOpen ? " is-open" : ""}`} type="button" id="ov-tri-btn" title="AI 生成商品三视图" aria-haspopup="dialog" aria-expanded={triOpen} onClick={() => setTriOpen((value) => !value)}>
|
<button className={`ov-edit ov-tri-trigger${triOpen ? " is-open" : ""}`} type="button" id="ov-tri-btn" title="AI 生成商品三视图" aria-haspopup="dialog" aria-expanded={triOpen} onClick={() => setTriOpen((value) => !value)}>
|
||||||
@@ -963,6 +1001,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* view 模式: 单个 [编辑信息] */}
|
{/* view 模式: 单个 [编辑信息] */}
|
||||||
<button className="ov-edit ov-edit-single" type="button" id="ov-edit-btn" title="编辑商品信息" onClick={startEditing}>
|
<button className="ov-edit ov-edit-single" type="button" id="ov-edit-btn" title="编辑商品信息" onClick={startEditing}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
|
||||||
@@ -992,12 +1031,26 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
<input className="v-edit v-input" type="text" value={name} maxLength={100} onChange={(event) => setName(event.target.value)} />
|
<input className="v-edit v-input" type="text" value={name} maxLength={100} onChange={(event) => setName(event.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row" data-field="type">
|
||||||
|
<div className="k">类目</div>
|
||||||
|
<div className="v">
|
||||||
|
<span className="v-static">{BUSINESS_TYPES[realType]}</span>
|
||||||
|
<select className="v-edit v-select" value={bizType} onChange={(event) => {
|
||||||
|
const next = event.target.value as BusinessType;
|
||||||
|
setBizType(next);
|
||||||
|
const opts = pdCatChoices(next, "");
|
||||||
|
if (!opts.includes(cat)) setCat(opts[0]);
|
||||||
|
}}>
|
||||||
|
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="row" data-field="cat">
|
<div className="row" data-field="cat">
|
||||||
<div className="k">品类</div>
|
<div className="k">品类</div>
|
||||||
<div className="v">
|
<div className="v">
|
||||||
<span className="v-static">{realCat}</span>
|
<span className="v-static">{realCat}</span>
|
||||||
<select className="v-edit v-select" value={cat} onChange={(event) => setCat(event.target.value)}>
|
<select className="v-edit v-select" value={cat} onChange={(event) => setCat(event.target.value)}>
|
||||||
{PD_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
|
{pdCatChoices(bizType, cat).map((option) => <option key={option}>{option}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1108,11 +1161,13 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
<div className="ov-h"><span className="ti">快速操作</span></div>
|
<div className="ov-h"><span className="ti">快速操作</span></div>
|
||||||
<div className="qa-section">
|
<div className="qa-section">
|
||||||
<div className="qa-section-h">// 图片生成</div>
|
<div className="qa-section-h">// 图片生成</div>
|
||||||
<div className="qa-row-3">
|
<div className={isLocal ? "qa-row-2" : "qa-row-3"}>
|
||||||
|
{!isLocal && (
|
||||||
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto", { productId: product.id })}>
|
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto", { productId: product.id })}>
|
||||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21v-2a4 4 0 014-4h8a4 4 0 014 4v2" /></svg></span>
|
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21v-2a4 4 0 014-4h8a4 4 0 014 4v2" /></svg></span>
|
||||||
模特上身图
|
模特上身图
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="qa-item" data-go="platform-cover" role="button" tabIndex={0} onClick={() => navigate("platformCover", { productId: product.id })}>
|
<div className="qa-item" data-go="platform-cover" role="button" tabIndex={0} onClick={() => navigate("platformCover", { productId: product.id })}>
|
||||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 3v18" /></svg></span>
|
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 3v18" /></svg></span>
|
||||||
平台套图
|
平台套图
|
||||||
@@ -1158,7 +1213,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>还没有 AI 素材</h3>
|
<h3>还没有 AI 素材</h3>
|
||||||
<p>// 用右侧「快速操作」生成模特上身图 / 平台套图 / 商品三视图</p>
|
<p>// 用右侧「快速操作」生成{isLocal ? "平台套图 / 图片创作" : "模特上身图 / 平台套图 / 商品三视图"}</p>
|
||||||
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}>去生成素材</button>
|
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}>去生成素材</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { CSSProperties, FormEvent } from "react";
|
import type { CSSProperties, FormEvent } from "react";
|
||||||
import type { Asset, Product, Project } from "../types";
|
import type { Asset, Product, Project, ScriptTemplate } from "../types";
|
||||||
|
import { api } from "../api";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
import { ConfirmModal, EmptyPanel, MediaLightbox } from "../components/overlays";
|
||||||
import { ProductCreateDrawer } from "../components/product-create-drawer";
|
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
|
||||||
|
import { isLocalLife } from "../product-business";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
import { SkeletonRows } from "../components/loading";
|
import { SkeletonRows } from "../components/loading";
|
||||||
import { useViewMode } from "../components/use-view-mode";
|
import { useViewMode } from "../components/use-view-mode";
|
||||||
@@ -14,13 +16,6 @@ const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
|
|||||||
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
|
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
|
||||||
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
||||||
|
|
||||||
type WizProductPayload = {
|
|
||||||
title: string;
|
|
||||||
category: string;
|
|
||||||
target_audience?: string;
|
|
||||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: {
|
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
projects?: Project[];
|
projects?: Project[];
|
||||||
@@ -29,7 +24,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
|
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
|
||||||
// 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
|
// 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
|
||||||
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
|
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
|
||||||
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
|
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
|
||||||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
||||||
}) {
|
}) {
|
||||||
@@ -57,6 +52,17 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
|||||||
// Step 2 · 配置(基础配置只保留项目名;脚本风格/人物设定与时长都在流水线第 1 步统一落定)
|
// Step 2 · 配置(基础配置只保留项目名;脚本风格/人物设定与时长都在流水线第 1 步统一落定)
|
||||||
const [points, setPoints] = useState<Record<string, boolean>>({});
|
const [points, setPoints] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Step 2 · 5.2 换商品重跑:选一个团队套路模板,新项目沿用它的镜序与节奏(文案仍按新商品重写)。
|
||||||
|
// 只传 template_id,套路参数由后端按库里的模板回填,前后端不留两份真相。
|
||||||
|
const [templates, setTemplates] = useState<ScriptTemplate[]>([]);
|
||||||
|
const [templateId, setTemplateId] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
void api.listScriptTemplates()
|
||||||
|
.then((page) => setTemplates(page.results || []))
|
||||||
|
.catch(() => setTemplates([]));
|
||||||
|
}, []);
|
||||||
|
const template = templates.find((t) => t.id === templateId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId && products[0]) setProductId(products[0].id);
|
if (!productId && products[0]) setProductId(products[0].id);
|
||||||
}, [productId, products]);
|
}, [productId, products]);
|
||||||
@@ -140,7 +146,8 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
|||||||
product: product.id,
|
product: product.id,
|
||||||
metadata: {
|
metadata: {
|
||||||
wizard: {
|
wizard: {
|
||||||
selling_point_ids: selectedPoints
|
selling_point_ids: selectedPoints,
|
||||||
|
...(templateId ? { template_id: templateId } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -250,7 +257,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
|||||||
)}
|
)}
|
||||||
<div className="product-body">
|
<div className="product-body">
|
||||||
<div className="product-name">{p.title}</div>
|
<div className="product-name">{p.title}</div>
|
||||||
<div className="product-cat">{p.category || "未分类"}</div>
|
<div className="product-cat">{isLocalLife(p) ? `本地生活${p.category ? " · " + p.category : ""}` : (p.category || "未分类")}</div>
|
||||||
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
|
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +294,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
|||||||
<div className="wiz-pane">
|
<div className="wiz-pane">
|
||||||
<div className="wiz-step-h">
|
<div className="wiz-step-h">
|
||||||
<h2>第 2 步 · 项目配置</h2>
|
<h2>第 2 步 · 项目配置</h2>
|
||||||
<p>基础配置只保留项目名;脚本来源、风格、人物设定和最终时长都在流水线第 1 步由脚本助手统一落定。</p>
|
<p>基础配置只保留项目名与可选的套路模板;脚本来源、风格、人物设定和最终时长都在流水线第 1 步由脚本助手统一落定。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -295,6 +302,28 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
|||||||
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 5.2 套路模板 · 没存过模板就整块不出现,不给空下拉占位 */}
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">套路模板(可选)</label>
|
||||||
|
<select className="input" value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||||
|
<option value="">不套用 · 让 AI 按这个商品从头设计</option>
|
||||||
|
{/* 默认模板名已含镜数,选项里只补时长与使用次数,避免「4 镜 · 4 镜」 */}
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name} · {t.total_duration}s{t.usage_count ? ` · 用过 ${t.usage_count} 次` : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」 */}
|
||||||
|
{template && (
|
||||||
|
<div className="tpl-expect">
|
||||||
|
<div className="te-head"><span className="mono">// 同套路重出</span>沿用「{template.name}」的镜数、每镜作用和节奏</div>
|
||||||
|
<div className="te-outline">{template.outline_text}</div>
|
||||||
|
<div className="te-note">文案、画面、模特与故事板都会按 {product?.title || "所选商品"} 重新生成 —— 不是把上一条片子换个商品名。生成后仍可在脚本页逐镜改。</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{Object.keys(points).length > 0 && (
|
{Object.keys(points).length > 0 && (
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
|
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
|
||||||
@@ -429,6 +458,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 成片播放:合成过的项目点播放键直接弹窗播成片,不再跳去视频生成页
|
||||||
|
const [playing, setPlaying] = useState<Project | null>(null);
|
||||||
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
|
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
|
||||||
const [catFilter, setCatFilter] = useState("");
|
const [catFilter, setCatFilter] = useState("");
|
||||||
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
|
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
|
||||||
@@ -649,7 +680,17 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
|||||||
{/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
|
{/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
|
||||||
{!editMode && (
|
{!editMode && (
|
||||||
<div className="row-action">
|
<div className="row-action">
|
||||||
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
|
{/* 合成过成片 → 直接弹窗播成片;没合成过 → 照旧进流水线继续做 */}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
title={project.final_video_url ? "播放成片" : "继续"}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (project.final_video_url) setPlaying(project);
|
||||||
|
else openPipeline(project.id);
|
||||||
|
}}
|
||||||
|
><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
|
||||||
<span className="row-more" onClick={(event) => event.stopPropagation()}>
|
<span className="row-more" onClick={(event) => event.stopPropagation()}>
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
|
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
|
||||||
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>删除项目</button></div>
|
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>删除项目</button></div>
|
||||||
@@ -702,6 +743,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
|||||||
<button type="button" onClick={() => setEditMode(false)}>完成</button>
|
<button type="button" onClick={() => setEditMode(false)}>完成</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MediaLightbox open={Boolean(playing)} src={playing?.final_video_url || ""} kind="video" name={`${playing?.name || ""} · 成片`} close={() => setPlaying(null)} />
|
||||||
|
|
||||||
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
|
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
|
||||||
<ConfirmModal open={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
|
<ConfirmModal open={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ export const VIDEO_STRUCTURES: Record<VideoStructure, string> = {
|
|||||||
export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[];
|
export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[];
|
||||||
export const STRUCTURE_KEYS = Object.keys(VIDEO_STRUCTURES) as VideoStructure[];
|
export const STRUCTURE_KEYS = Object.keys(VIDEO_STRUCTURES) as VideoStructure[];
|
||||||
|
|
||||||
|
const FORMAT_KEY_BY_LABEL: Record<string, PresentationFormat> = Object.fromEntries(
|
||||||
|
(Object.entries(PRESENTATION_FORMATS) as Array<[PresentationFormat, string]>).map(([key, label]) => [label, key])
|
||||||
|
);
|
||||||
|
const STRUCTURE_KEY_BY_LABEL: Record<string, VideoStructure> = Object.fromEntries(
|
||||||
|
(Object.entries(VIDEO_STRUCTURES) as Array<[VideoStructure, string]>).map(([key, label]) => [label, key])
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 脚本落库存的是中文标签(「短剧」),设定卡下拉用的是 key(drama)。两边都认。 */
|
||||||
|
export function coercePresentationFormat(value: unknown, fallback: PresentationFormat = "oral"): PresentationFormat {
|
||||||
|
if (typeof value !== "string" || !value) return fallback;
|
||||||
|
if (value in PRESENTATION_FORMATS) return value as PresentationFormat;
|
||||||
|
return FORMAT_KEY_BY_LABEL[value] || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceVideoStructure(value: unknown, fallback: VideoStructure = "pain"): VideoStructure {
|
||||||
|
if (typeof value !== "string" || !value) return fallback;
|
||||||
|
if (value in VIDEO_STRUCTURES) return value as VideoStructure;
|
||||||
|
return STRUCTURE_KEY_BY_LABEL[value] || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/** 一句话说清每个选项是什么,挂在下拉旁边当说明,别让用户猜。 */
|
/** 一句话说清每个选项是什么,挂在下拉旁边当说明,别让用户猜。 */
|
||||||
export const PRESENTATION_HINT: Record<PresentationFormat, string> = {
|
export const PRESENTATION_HINT: Record<PresentationFormat, string> = {
|
||||||
oral: "一个人对镜头说,信息密度最高、最省制作",
|
oral: "一个人对镜头说,信息密度最高、最省制作",
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ export type Product = {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
|
/** 类目:ecommerce=电商 · local_life=本地生活。跟 category(品类)是两个维度。 */
|
||||||
|
business_type?: "ecommerce" | "local_life" | string;
|
||||||
category: string;
|
category: string;
|
||||||
target_audience: string;
|
target_audience: string;
|
||||||
specs?: Record<string, unknown>;
|
specs?: Record<string, unknown>;
|
||||||
@@ -474,6 +476,26 @@ export type TimelineSavePayload = {
|
|||||||
draft?: Record<string, unknown>;
|
draft?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 套路模板(5.1)· 只承载「怎么讲」:镜数 / 每镜作用 / 节奏 / 商品露出 / 转化写法,不含旧商品文案 */
|
||||||
|
export type ScriptTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
presentation_format: string;
|
||||||
|
video_structure: string;
|
||||||
|
persona: string;
|
||||||
|
total_duration: number;
|
||||||
|
outline: Array<{ index: number; role: string; duration: number; product_exposure: string; delivery: string }>;
|
||||||
|
outline_text: string;
|
||||||
|
shot_count: number;
|
||||||
|
cta: string;
|
||||||
|
scenes: string[];
|
||||||
|
source_project: string | null;
|
||||||
|
source_project_name?: string;
|
||||||
|
usage_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -488,6 +510,8 @@ export type Project = {
|
|||||||
cover_preview_url?: string;
|
cover_preview_url?: string;
|
||||||
script_version_count?: number;
|
script_version_count?: number;
|
||||||
video_segment_count?: number;
|
video_segment_count?: number;
|
||||||
|
// 合成成片地址(最新一次成功拼接):列表播放按钮 / 视频阶段「播放成片」直接用它;没合成过为空串
|
||||||
|
final_video_url?: string;
|
||||||
stages: ProjectStage[];
|
stages: ProjectStage[];
|
||||||
script_versions: ScriptVersion[];
|
script_versions: ScriptVersion[];
|
||||||
video_segments: VideoSegment[];
|
video_segments: VideoSegment[];
|
||||||
@@ -520,6 +544,10 @@ export type Project = {
|
|||||||
presentation_format?: string;
|
presentation_format?: string;
|
||||||
video_structure?: string;
|
video_structure?: string;
|
||||||
total_duration?: number;
|
total_duration?: number;
|
||||||
|
// 5.2 换商品重跑:新建向导只传 template_id,name/outline 由后端按库里的模板回填
|
||||||
|
template_id?: string;
|
||||||
|
template_name?: string;
|
||||||
|
template_outline?: string;
|
||||||
};
|
};
|
||||||
cast?: string[];
|
cast?: string[];
|
||||||
scenes?: string[];
|
scenes?: string[];
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 端到端跑一次「合成完整视频」:提交 → 轮询进度 → 拿成片地址(验证 ffmpeg 真拼出来了)
|
||||||
|
const API = process.env.API || "http://127.0.0.1:8010";
|
||||||
|
const PROJECT = process.argv[2];
|
||||||
|
if (!PROJECT) throw new Error("用法: node _merge-e2e.mjs <projectId>");
|
||||||
|
|
||||||
|
const login = await (await fetch(`${API}/api/auth/login/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "airshelf", password: "Restraint2026" })
|
||||||
|
})).json();
|
||||||
|
const auth = { Authorization: `Token ${login.token}`, "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
const submit = await fetch(`${API}/api/projects/${PROJECT}/submit-export/`, { method: "POST", headers: auth });
|
||||||
|
console.log("submit:", submit.status, JSON.stringify(await submit.json()));
|
||||||
|
|
||||||
|
// 顺手验一下防重复:立刻再提一次,应该拿到同一条任务
|
||||||
|
const again = await (await fetch(`${API}/api/projects/${PROJECT}/submit-export/`, { method: "POST", headers: auth })).json();
|
||||||
|
console.log("再提一次(应复用同一任务):", again.id, again.status);
|
||||||
|
|
||||||
|
for (let i = 0; i < 200; i += 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const res = await (await fetch(`${API}/api/projects/${PROJECT}/poll-export/`, { method: "POST", headers: auth })).json();
|
||||||
|
const secs = ((Date.now() - t0) / 1000).toFixed(0);
|
||||||
|
console.log(` ${secs}s · ${res.status} · ${res.progress}%`, res.error_message || "");
|
||||||
|
if (res.status === "succeeded") {
|
||||||
|
console.log("成片:", res.output_url);
|
||||||
|
const head = await fetch(res.output_url, { method: "HEAD" });
|
||||||
|
console.log("成片可访问:", head.status, head.headers.get("content-type"), head.headers.get("content-length"), "bytes");
|
||||||
|
const detail = await (await fetch(`${API}/api/projects/${PROJECT}/`, { headers: auth })).json();
|
||||||
|
console.log("详情 final_video_url:", detail.final_video_url ? "有" : "空", "· current_stage:", detail.current_stage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (res.status === "failed") { console.log("失败:", res.error_message); break; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// 抓图:视频阶段「合成完整视频 / 播放成片 / 下载成片」+ 项目列表播放键播成片
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || "http://127.0.0.1:5173";
|
||||||
|
const API = process.env.API || "http://127.0.0.1:8010";
|
||||||
|
const OUT = process.argv[2] || "shots-merge";
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const login = await (await fetch(`${API}/api/auth/login/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "airshelf", password: "Restraint2026" })
|
||||||
|
})).json();
|
||||||
|
const tok = login.token;
|
||||||
|
const auth = { Authorization: `Token ${tok}`, "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
// 挑一个「所有场次都已出片」的项目:合成按钮才是可点态
|
||||||
|
const projects = (await (await fetch(`${API}/api/projects/?page_size=100`, { headers: auth })).json()).results || [];
|
||||||
|
console.log("项目数:", projects.length, "· 已有成片:", projects.filter((p) => p.final_video_url).length);
|
||||||
|
let target = null;
|
||||||
|
for (const p of projects.filter((p) => p.status === "completed" || p.current_stage === "video")) {
|
||||||
|
const detail = await (await fetch(`${API}/api/projects/${p.id}/`, { headers: auth })).json();
|
||||||
|
const segs = detail.video_segments || [];
|
||||||
|
if (segs.length && segs.every((s) => s.status === "succeeded" && s.adopted_asset)) {
|
||||||
|
target = detail;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target) throw new Error("没有全部场次出片的项目,无法抓合成态");
|
||||||
|
console.log("target:", target.id, target.name, "· 场次:", target.video_segments.length, "· 成片:", target.final_video_url || "(未合成)");
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
|
||||||
|
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error(" pageerror:", e.message));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/pipeline/${target.id}#stage-4`, { waitUntil: "networkidle", timeout: 40000 });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const foot = page.locator('[data-stage-pane="4"] .stage-foot');
|
||||||
|
await foot.waitFor({ timeout: 15000 });
|
||||||
|
await page.screenshot({ path: `${OUT}/1-stage4-merge-bar.png`, clip: await foot.boundingBox() });
|
||||||
|
console.log("shot 1-stage4-merge-bar");
|
||||||
|
|
||||||
|
// 点「播放成片」→ 灯箱播成片
|
||||||
|
const play = page.locator('[data-stage-pane="4"] .stage-foot button', { hasText: "播放成片" });
|
||||||
|
if (await play.count()) {
|
||||||
|
await play.click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.screenshot({ path: `${OUT}/2-stage4-play-final.png` });
|
||||||
|
console.log("shot 2-stage4-play-final");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/projects`, { waitUntil: "networkidle", timeout: 40000 });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await page.screenshot({ path: `${OUT}/3-projects-list.png`, fullPage: false });
|
||||||
|
console.log("shot 3-projects-list");
|
||||||
|
|
||||||
|
// 列表行末播放键:有成片 → 直接弹窗播,不再跳流水线
|
||||||
|
const row = page.locator(`tr[data-name="${target.name}"] .row-action a`).first();
|
||||||
|
if (await row.count()) {
|
||||||
|
await row.click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.screenshot({ path: `${OUT}/4-projects-play-final.png` });
|
||||||
|
console.log("shot 4-projects-play-final · 当前地址:", page.url());
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log("DONE ->", OUT);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// 二期第7段验收抓图:5.1 存为模板入口 + 弹窗;5.2 新建项目选模板;5.3 同套路重出预期文案
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || "http://127.0.0.1:5174";
|
||||||
|
const API = process.env.API || "http://127.0.0.1:8011";
|
||||||
|
const OUT = process.argv[2] || "shots-seg7";
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const login = await (await fetch(`${API}/api/auth/login/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "seg7", password: "Restraint2026" })
|
||||||
|
})).json();
|
||||||
|
const tok = login.token;
|
||||||
|
const auth = { Authorization: `Token ${tok}`, "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
const projects = (await (await fetch(`${API}/api/projects/`, { headers: auth })).json()).results || [];
|
||||||
|
const project = projects.find((p) => p.name.includes("夜光面膜"));
|
||||||
|
console.log("project:", project.id, project.name);
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 2 });
|
||||||
|
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error(" pageerror:", e.message));
|
||||||
|
|
||||||
|
async function shot(name, locator) {
|
||||||
|
const clip = locator ? await locator.boundingBox() : undefined;
|
||||||
|
await page.screenshot({ path: `${OUT}/${name}.png`, ...(clip ? { clip } : { fullPage: false }) });
|
||||||
|
console.log("shot", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1 · 脚本页底栏「存为模板」入口 ──
|
||||||
|
await page.goto(`${BASE}/pipeline/${project.id}?st=1#stage-1`, { waitUntil: "networkidle", timeout: 40000 });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const foot = page.locator(".stage-foot").first();
|
||||||
|
await foot.scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await shot("1-save-template-entry", foot);
|
||||||
|
|
||||||
|
// ── 2 · 存为模板弹窗:摊开「会存进模板的内容」 ──
|
||||||
|
await page.getByRole("button", { name: /存为模板/ }).click();
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await shot("2-save-template-modal", page.locator(".modal").first());
|
||||||
|
|
||||||
|
const nameInput = page.locator(".modal .input").first();
|
||||||
|
await nameInput.fill(`口播 · 痛点前置 · 4 镜 #${Date.now() % 1000}`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.getByRole("button", { name: /保存模板/ }).click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await shot("3-saved-toast");
|
||||||
|
|
||||||
|
const templates = (await (await fetch(`${API}/api/script-templates/`, { headers: auth })).json()).results || [];
|
||||||
|
console.log("templates:", templates.map((t) => `${t.name} / ${t.shot_count}镜 / ${t.total_duration}s`));
|
||||||
|
console.log("outline_text:\n" + (templates[0]?.outline_text || "(空)"));
|
||||||
|
|
||||||
|
// ── 4 · 新建项目 · 第 2 步选模板 + 预期说明 ──
|
||||||
|
await page.goto(`${BASE}/projects/new`, { waitUntil: "networkidle", timeout: 40000 });
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
const cards = page.locator(".product-card");
|
||||||
|
const count = await cards.count();
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
if ((await cards.nth(i).innerText()).includes("鎏金精华水")) { await cards.nth(i).click(); break; }
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const select = page.locator(".wiz-pane select.input").first();
|
||||||
|
await select.selectOption({ index: 1 });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
const pane = page.locator(".step-pane-wrap").last();
|
||||||
|
await pane.scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await shot("4-wizard-template-picked", pane);
|
||||||
|
|
||||||
|
// ── 5 · 用模板开新项目 → 脚本页设定卡已带模板参数 + 预期文案 ──
|
||||||
|
const created = await (await fetch(`${API}/api/projects/`, {
|
||||||
|
method: "POST", headers: auth,
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `鎏金精华水 · 同套路 · ${Date.now() % 10000}`,
|
||||||
|
product: (await (await fetch(`${API}/api/products/`, { headers: auth })).json()).results.find((p) => p.title === "鎏金精华水").id,
|
||||||
|
metadata: { wizard: { template_id: templates[0].id, selling_point_ids: [] } }
|
||||||
|
})
|
||||||
|
})).json();
|
||||||
|
console.log("new project wizard:", JSON.stringify(created.metadata.wizard, null, 2));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/pipeline/${created.id}?st=1#stage-1`, { waitUntil: "networkidle", timeout: 40000 });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.locator('.chat-mode[data-mode="ai"]').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await shot("5-setup-card-with-template", page.locator(".chat-pane").first());
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log("done ->", OUT);
|
||||||
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 474 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 50 KiB |