`,渲染成进度卡列表;同 id 更新状态。
+- `reasoning`:追加到「思考过程」可折叠区(灰字、逐字滚动)。
+- `delta`:追加到 AI 前言气泡。
+- `draft`:用结构化数据渲染分镜卡片(hook/tone/segments),可直接编辑。
+- `saved`:拿 `script_version_id` 标记当前稿,`version` 是完整序列化对象可直接入列表。
+- `summary`:作为 AI 的收尾回复气泡(没有则用默认句兜底)。
+- `done`:关闭 loading。
+- `error`:弹 `detail`,此时后端已回滚额度,前端无需补偿。
+
+---
+
+## 6. 涉及文件索引
+
+| 文件 | 角色 |
+| ---- | ---- |
+| [`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) | 本文主体:`stream_script_agent` 编排 + normalize/merge/persist |
+| [`apps/projects/views.py`](../backend/apps/projects/views.py) | `script_agent_stream` 端点 + `ServerSentEventRenderer` |
+| [`apps/ai/providers/volcano.py`](../backend/apps/ai/providers/volcano.py) | `chat_completion_stream` 底层 SSE,reasoning/delta 分流 |
+| [`apps/ai/services.py`](../backend/apps/ai/services.py) | `build_provider` 可插拔分流、`create_ai_task` 预扣 |
+| [`apps/billing/services/ledger.py`](../backend/apps/billing/services/ledger.py) | `reserve/charge/release_credit` |
+| [`skills/ecommerce-video-script/SKILL.md`](../backend/skills/ecommerce-video-script/SKILL.md) | 领域知识(系统提示词) |
diff --git a/core/docs/脚本Agent编排架构方案-动态知识装配.md b/core/docs/脚本Agent编排架构方案-动态知识装配.md
new file mode 100644
index 0000000..7866b9b
--- /dev/null
+++ b/core/docs/脚本Agent编排架构方案-动态知识装配.md
@@ -0,0 +1,311 @@
+# 脚本 Agent 编排架构方案 · 动态知识装配(流式管道)
+
+> 状态:设计方案(未落地代码) · 作者:架构评审 · 日期:2026-06-24
+> 关联代码:[`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) · [`apps/ai/services.py`](../backend/apps/ai/services.py) · [`skills/ecommerce-video-script/`](../backend/skills/ecommerce-video-script/)
+> 关联文档:[脚本Agent流式SSE技术文档.md](脚本Agent流式SSE技术文档.md)(现状) · [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md)(实测数据)
+
+---
+
+## 1. 背景与要解决的问题
+
+### 1.1 现状
+
+当前脚本生成把领域知识一次性全量灌入上下文:`load_ecommerce_skill()`(见 [script_agent.py:54](../backend/apps/ai/script_agent.py#L54))
+用 `glob("*.md")` **无条件遍历全部 references**,整篇拼成系统提示词,每次对话(不论全自动/一句话/改稿/改一镜)
+都把这 ~28K 字符全量发给模型。
+
+SKILL.md 里虽写了一张「输入模式路由 / 参考资料索引」表(指明哪个品类该读哪几篇),但**该路由仅作为提示词
+发给模型,后端并未按它选择性加载**——检索发生在模型的注意力里,不在后端。
+
+### 1.2 随业务增长的问题
+
+> **核心痛点:上下文随电商品类数量线性膨胀。**
+
+现在 5 个 references = 28K 字符。未来叠加更多品类话术(美妆/食品/3C/服饰/家居/母婴/宠物/家电…)
+与平台调性后,全量灌入会:
+
+- **上下文臃肿**:单次请求 system prompt 可能涨到数十万字符,逼近/超出上下文窗口;
+- **成本线性上涨**:每次都付全量知识的 token,即便这单商品只用得上其中一个品类包;
+- **注意力稀释**:无关品类的话术挤占模型注意力,可能拉低相关品类的发挥;
+- **缓存难命中**:动态拼接的大 prompt 难以稳定复用 prefix cache。
+
+### 1.3 目标
+
+把「静态全量灌入」改为「**按需动态装配**」:拿到商品需求后,**只加载与该商品相关的知识模块**,
+打包给模型,流式生成,再经过滤/提取输出前端。**单次上下文只随"命中的 1-2 个品类包"走,不随品类总量膨胀。**
+
+---
+
+## 2. 设计目标(验收标准)
+
+| # | 目标 | 可度量标准 |
+| - | ---- | ---------- |
+| G1 | 上下文不随品类总数膨胀 | 单次 system prompt 字数 ≈ 内核 + 命中模块,与品类总数解耦 |
+| G2 | 输出格式永不因知识缺失而塌 | 任意路由结果下,输出契约恒在 prompt 中 |
+| G3 | 全程流式 | 路由/装配/生成/提取每阶段都有 SSE 进度事件 |
+| G4 | 运营可扩品类不改代码 | 加一个品类 = 加一个知识模块(文件/DB),无需改 Python |
+| G5 | 不引入与任务不匹配的重型框架 | 沿用生成器流式编排,不上状态图运行时 |
+| G6 | 平滑迁移 | 分阶段落地,每阶段可独立上线、可回滚 |
+
+---
+
+## 3. 总体架构:六段流式管道
+
+```
+商品需求(product + 前置条件)
+ │
+ ▼ ① 路由 Router ────────── 识别品类/平台 → 决定加载哪些知识模块
+ │ SSE: tool router "识别品类:美妆洗护 · 平台:抖音"
+ │
+ ▼ ② 装配 Assembler ─────── 取「内核 + 命中品类包 + 命中平台调性」
+ │ SSE: tool assemble "装配知识:内核+2模块 共 9.2K 字" ← 可见地证明未膨胀
+ │
+ ▼ ③ 打包 Packager ──────── 内核(恒在) + 动态知识 + 商品上下文 + 输出契约
+ │
+ ▼ ④ 生成 Generator ─────── 约束解码(tool/structured)流式出结构
+ │ SSE: reasoning(思考) / delta(口语前言)
+ │
+ ▼ ⑤ 过滤提取 Extractor ─── 校验/归一/抽实体/扫违规词(强不变量,后端兜底)
+ │ SSE: tool extract "提取实体4个 · 自检通过"
+ │
+ ▼ ⑥ 前端 Sink ──────────── draft / saved / summary / done
+```
+
+**与现状的本质差异**:②③ 从"glob 全部"变为"只装命中"。①②⑤ 是真实工作步骤,不再是装样子的工具卡。
+
+---
+
+## 4. 核心设计:知识分两层
+
+把现有单块 28K 知识拆成**内核(恒在)+ 模块(动态)**两层。
+
+### 4.1 内核 Kernel(每次必带,小而稳)
+
+| 内容 | 来源(现状) |
+| ---- | ---------- |
+| 黄金结构(钩子→痛点→卖点→CTA)、档位×结构映射 | methodology.md 的结构部分 |
+| **输出契约(铁律1):字段名锚定、JSON 形状、镜数规则** | SKILL.md 铁律1 + `_OUTPUT_PROTOCOL` |
+| 写作红线:≤55字、违规词清单、口语化 | methodology.md 红线 + SKILL.md 铁律3 |
+| 字段纪律:tone/role 枚举、entity 引用合法性 | SKILL.md 铁律1 |
+
+> ⚠️ **输出契约必须在内核里,永远在。** 这是你们踩过的坑(skills 没进镜像→契约丢失→模型吐散文解析失败)
+> 的正式解。无论路由加载了哪些品类包,格式硬底线都不会塌。现有 `_EXTRACT_OUTPUT_CONTRACT`
+> (见 [services.py:385](../backend/apps/ai/services.py#L385))写死兜底,就是这一思想的雏形——把它正式化为"内核"。
+
+### 4.2 品类模块 Module(按商品命中才加载,多而长)
+
+| 模块类型 | 例 | frontmatter 选择维度 |
+| ------- | -- | ------------------- |
+| 品类话术 | 美妆/食品/3C/服饰/家居… | `applies_to: [category...]` |
+| 平台调性 | 抖音/快手/小红书/视频号 | `platforms: [...]` |
+| 钩子库分册 | 痛点提问/反差/数字冲击… | `tone: [...]` 或 always |
+
+每个模块是一个独立的、带元数据索引的知识单元(不再是一坨大 concat)。
+
+---
+
+## 5. 路由机制:怎么选模块
+
+三种机制,按本场景适配度排序。**推荐 rule-first 混合**。
+
+### 5.1 元数据路由(主力 · 先落地这个)
+
+把 SKILL.md 的路由表**从提示词搬进代码**:每个模块 frontmatter 声明它服务的品类/平台,
+`select_knowledge(product)` 按 `product.category` / 平台前置条件命中。
+
+- **优点**:零额外调用、确定性、可解释、可单测。电商商品基本都有 category 字段,**80% 情况足够**。
+- **缺点**:新品类要维护映射——但加品类 = 加一个 `.md` 模块 + 写 frontmatter,**不改 Python**(满足 G4)。
+
+### 5.2 向量检索 RAG(扩容兜底 · 品类破百再上)
+
+把知识块 embedding,用商品上下文检索 top-K 相关片段。
+
+- **优点**:处理模糊/新品类(novel category 自动匹配近邻),可无限扩。
+- **缺点**:引入检索失败模式(检错块→知识缺失)、需 embedding 基建与运维。**别过早引入。**
+
+### 5.3 LLM 路由(灵活但加跳)
+
+用便宜快模型(如 doubao-lite)先分类"该商品属哪类、用哪套话术"。
+
+- **优点**:最灵活,能理解复杂商品描述。**缺点**:多一次调用 + 延迟。
+
+### 5.4 推荐:rule-first 混合
+
+```
+select_knowledge(product):
+ modules = [Kernel] # 恒在
+ hit = rule_match(product.category, platform) # 5.1 规则命中
+ if hit:
+ modules += hit
+ else:
+ modules += fallback() # 命中不到:回落(全量核心包 or 5.2 检索)
+ return modules
+```
+
+**先只做 5.1 规则版,留好 `fallback()` 接口**;品类规模或模糊度上来时,把 `fallback` 换成检索/LLM 路由。
+
+---
+
+## 6. 接口设计
+
+### 6.1 知识模块结构(frontmatter 规范)
+
+每个 reference 模块在文件头加 YAML frontmatter(或等价 DB 字段):
+
+```markdown
+---
+id: playbook-beauty
+type: category # core | category | platform | hook
+applies_to: [美妆, 护肤, 洗护, 彩妆] # 命中这些 category 时加载
+platforms: [] # 限定平台(空=不限)
+keywords: [精华, 面膜, 口红, 防晒] # 检索/模糊命中用
+priority: 10
+enabled: true
+---
+(正文:该品类的话术、语气、卖点侧重…)
+```
+
+`core` 类型 = 内核,恒加载;其余按 `applies_to`/`platforms`/`keywords` 命中。
+
+### 6.2 选择函数(替换 `load_ecommerce_skill`)
+
+```python
+# apps/ai/knowledge.py(新增)
+@dataclass
+class KnowledgeModule:
+ id: str
+ type: str # core|category|platform|hook
+ applies_to: list[str]
+ platforms: list[str]
+ keywords: list[str]
+ body: str
+
+def load_registry() -> list[KnowledgeModule]:
+ """扫 skills/ 下模块(含 frontmatter),或读 DB。缓存。"""
+
+def select_knowledge(*, product, platform: str | None, mode: str) -> list[KnowledgeModule]:
+ """rule-first:内核恒在 + 按 category/platform 命中品类包/平台调性;
+ 命中不到走 fallback(全量核心 or 检索)。改稿模式可少带选题类模块。"""
+
+def assemble_system_prompt(modules: list[KnowledgeModule]) -> tuple[str, int]:
+ """拼 system prompt = 内核(置顶稳定,利于 prefix cache) + 动态模块。
+ 返回 (prompt, 字数) —— 字数用于 SSE 上报,可见证明未膨胀。"""
+```
+
+`build_agent_messages`(见 [script_agent.py:111](../backend/apps/ai/script_agent.py#L111))
+的 `system = load_ecommerce_skill() + _OUTPUT_PROTOCOL` 改为
+`system, n = assemble_system_prompt(select_knowledge(...))`,其中输出契约并入内核。
+
+### 6.3 SSE 事件扩展
+
+在现有 `tool/reasoning/delta/draft/saved/summary/done` 基础上,让 ①②⑤ 成为**真实**工具卡:
+
+| 事件 | 新增/变化 | 载荷 |
+| ---- | -------- | ---- |
+| `tool: router` | 新增 | `{label:"识别品类:美妆·抖音", status, meta:{category, platform}}` |
+| `tool: assemble` | 新增 | `{label:"装配知识 内核+2模块 9.2K字", status, meta:{module_ids, chars}}` |
+| `tool: generate` | 不变 | 约束解码流式 |
+| `tool: extract` | 强化 | 真实体提取 + 违规词自检结果 |
+
+> `assemble` 卡把"这次只装了 9.2K 而非 28K"**可观测地**展示给用户/运维,是 G1 的活体证明。
+
+---
+
+## 7. 生成与过滤提取(④⑤)
+
+### 7.1 生成:约束解码,让 normalize 退居安全网
+
+结合 [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md) 的实测结论:
+
+- 三模型(豆包/GPT-5.5/Gemini-3.1-pro)的 structured/tool 均可产出 `raw_clean✓` 的契约 JSON;
+- **freeform 下三家原始输出全 `raw_clean✗`**(靠 `normalize_draft` fuzzy 抢救);
+- **tool 策略跨模型 segments 键集完全同构**(最稳)。
+
+→ 生成阶段改用 **tool/structured 约束解码**(schema 须补全 `dialogue/speaker/voice_ref` 等契约字段),
+内核保留输出契约文字作双保险。`normalize_draft` 从"主力解析器"降为"安全网"。
+
+> ⚠️ 约束解码与"先写口语前言"的 `_OUTPUT_PROTOCOL` 有张力(实测 GPT structured 被前言污染)。
+> 落地时**对话气泡(前言/收尾)与结构稿分离**:结构走纯约束,气泡另起轻量一跳或用支持混合流的部件协议。
+
+### 7.2 过滤提取:强不变量后端兜底
+
+沿用并简化现有逻辑(约束解码后原始已干净,兜底压力骤降):
+
+- 镜数对齐(=时长/15)、role/tone 枚举归一、entity_refs 合法性 —— `normalize_draft` 现有能力;
+- 实体抽取(角色/场景)—— 复用 [services.py](../backend/apps/ai/services.py) 的 `run_extract_entities_task`;
+- 违规词自检 —— 可前置为真校验节点(发现即标记/可触发重生成)。
+
+---
+
+## 8. 为什么不用 LangGraph
+
+本管道是**线性流水线**(router→assemble→generate→extract→sink):**无环、无 reflect-retry、无多 agent 对话**。
+线性 + 流式正是现有生成器范式的最佳 altitude。LangGraph 的状态图是为"有环/有分支/要回退/human-in-loop"
+设计的,**此处上图属过度设计**。真正的编排升级点是"选择性装配"这一层抽象,而非更换运行时。
+
+> 若未来产品要做「生成→自检违规词→自动修正→再检」的真闭环,或「编剧/审查/提取」多 agent 协作,
+> 那时再评估 LangGraph / PydanticAI(类型契约+重试)/ 轻量自写 retry。当前不需要。
+
+---
+
+## 9. 分阶段迁移清单
+
+每阶段可独立上线、独立回滚。
+
+### Phase 1 · 知识分层 + 规则路由(止血膨胀,优先级最高)
+- [ ] references 加 frontmatter(`type/applies_to/platforms/keywords`);抽出 `core` 内核。
+- [ ] 新增 `apps/ai/knowledge.py`:`load_registry` / `select_knowledge`(规则版)/ `assemble_system_prompt`。
+- [ ] `build_agent_messages` 改用 `assemble_system_prompt(select_knowledge(...))`;**输出契约并入内核**。
+- [ ] SSE 增 `router`/`assemble` 真实工具卡(含字数)。
+- [ ] 单测:命中/未命中/改稿模式各自加载了哪些模块;内核必含契约。
+- **验收**:单次 system prompt 字数与品类总数解耦(G1);格式零回归。
+
+### Phase 2 · 约束解码(让 normalize 退居安全网)
+- [ ] 定义完整 `ScriptDraft` JSON Schema(含 dialogue/speaker/voice_ref)。
+- [ ] 生成阶段切 tool/structured(按 provider 能力分流,实测已验证三家可行)。
+- [ ] 对话气泡与结构稿分离,解决 `_OUTPUT_PROTOCOL` 与约束的张力。
+- [ ] `normalize_draft` 降级为兜底;保留以防个别模型/中转站不合规。
+- **验收**:三模型原始输出 `raw_clean✓`;normalize 命中率(需抢救比例)大幅下降。
+
+### Phase 3 · 检索扩容(品类规模化后才做)
+- [ ] 知识块 embedding + 向量库;`fallback()` 接入检索。
+- [ ] 模糊/新品类召回评估。
+- **验收**:新增品类无需改路由规则即可被正确召回。
+
+### Phase 4(可选)· 自检闭环
+- [ ] 违规词/字数校验做成真节点,不过则带错误自动重生成(轻量 retry,非全图)。
+
+---
+
+## 10. 风险与对策
+
+| 风险 | 对策 |
+| ---- | ---- |
+| **R1 内核漏放契约 → 某品类下格式塌** | 内核**必含**完整输出契约;单测断言"任意路由结果都含契约";保留写死兜底。 |
+| **R2 路由漏召(该加载却没加载)→ 模型瞎编** | 漏召比误召危险。规则命中不到**必须回落**(全量核心包 or 检索),严禁裸奔。 |
+| **R3 约束解码与对话气泡冲突** | 结构稿走纯约束、气泡分离(见 7.1);或用支持混合流的部件协议。 |
+| **R4 prefix cache 未命中,内核成本没摊薄** | 内核置顶且稳定;实测豆包/中转是否支持 prefix cache 再定。 |
+| **R5 检索引入新失败模式** | Phase 3 才上;上之前用规则兜底;检索结果可解释、可回退规则。 |
+
+---
+
+## 11. 与现有代码映射(速查)
+
+| 设计组件 | 现状 | 落点 |
+| ------- | ---- | ---- |
+| 内核 + 模块拆分 | `load_ecommerce_skill()` glob 全部 | 新 `apps/ai/knowledge.py` |
+| 路由 `select_knowledge` | SKILL.md 路由表(给模型看) | 新 `knowledge.py`,规则实现 |
+| 装配 `assemble_system_prompt` | 字符串拼全部 | 新 `knowledge.py`,内核+命中 |
+| 打包 | `build_agent_messages` system 拼接 | 改 [script_agent.py:122](../backend/apps/ai/script_agent.py#L122) |
+| 生成(约束解码) | freeform + `_OUTPUT_PROTOCOL` | 改 provider 调用,见 [providers/](../backend/apps/ai/providers/) |
+| 过滤提取 | `normalize_draft` 当主力 | 降为安全网 [script_agent.py:307](../backend/apps/ai/script_agent.py#L307) |
+| 流式编排 | `stream_script_agent` 生成器 | 沿用,增 router/assemble 事件 [script_agent.py:561](../backend/apps/ai/script_agent.py#L561) |
+| 模块运营管理 | `references/*.md` 文件 | frontmatter 文件,或复用 `PromptTemplate` admin DB 模式 |
+
+---
+
+## 12. 一句话总结
+
+把「静态全量灌」改成「**内核恒在 + 品类模块按需装配**」的**线性流式管道**:路由先用规则(SKILL 路由表搬进代码)、
+扩容再上检索;生成换约束解码让 `normalize_draft` 退居安全网;流式编排沿用现有生成器,**不需要 LangGraph**。
+如此品类再叠,单次上下文也只随"命中的一两个包"走,**不随品类总量膨胀**。
diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css
index 3450a01..2ccda6d 100644
--- a/core/frontend/src/ai-tools-page.css
+++ b/core/frontend/src/ai-tools-page.css
@@ -170,14 +170,20 @@
在 .content 内铺满可用高度(shell 由 App.tsx 渲染,这里只占正文)。
============================================================ */
.image-workbench {
- /* 抵消 .content 的 48/28/72 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路) */
- margin: -48px -28px -72px;
+ /* 抵消 .content 的 24/28/60 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路)。
+ 必须与 design-restraint.css .content 的 padding 严格一致:多减则顶部钻进 sticky
+ topbar(被遮)、底部溢出视口(被裁)。 */
+ margin: -24px -28px -60px;
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-base);
overflow: hidden;
}
+/* <1100px 时 .content 改 28/24/48,负边距需同步,否则窄屏又会上下被遮/裁 */
+@media (max-width: 1100px) {
+ .image-workbench { margin: -28px -24px -48px; }
+}
/* ════════════════════════════════════════════════
通用:返回 pill(图片创作侧栏头 / 模特·平台侧栏头共用)
@@ -488,6 +494,18 @@
border-color: var(--heat-40);
font-weight: 600;
}
+/* 手动输入比例:宽 : 高 两个数字框 */
+.image-workbench .iw-ratio-manual {
+ display: flex; align-items: center; gap: 8px;
+ margin-top: 8px;
+}
+.image-workbench .iw-ratio-manual .input {
+ flex: 1; min-width: 0; height: 32px;
+ text-align: center;
+}
+.image-workbench .iw-ratio-manual .sep {
+ color: var(--black-alpha-48); font-size: 13px; flex-shrink: 0;
+}
/* ── 模特选择 · 3:4 矩形卡多选(基线 .model-card)── */
.image-workbench .model-grid {
@@ -522,11 +540,14 @@
object-fit: cover; display: block;
background: var(--black-alpha-4);
}
-.image-workbench .model-card .m-name { font-size: 13px; font-weight: 500; color: var(--accent-black); }
-.image-workbench .model-card.selected .m-name { color: var(--heat); }
-.image-workbench .model-card .m-tag {
- font-family: var(--font-mono); font-size: 12px;
- color: var(--black-alpha-48); letter-spacing: .02em;
+/* 标题:遮罩层贴图片底部,白字单行省略,节省一行纵向空间 */
+.image-workbench .model-card .m-name {
+ position: absolute; left: 0; right: 0; bottom: 0;
+ padding: 14px 8px 6px;
+ font-size: 13px; font-weight: 500; color: #fff;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ background: linear-gradient(to top, rgba(0, 0, 0, .62), rgba(0, 0, 0, 0));
+ pointer-events: none; z-index: 1;
}
.image-workbench .model-card .m-check {
position: absolute; top: 14px; right: 14px;
diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx
index d3d5ac1..4b295a2 100644
--- a/core/frontend/src/routes/ai-tools.tsx
+++ b/core/frontend/src/routes/ai-tools.tsx
@@ -432,7 +432,7 @@ const MODE_META: Record<
const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
const COUNT_OPTIONS = ["1", "2", "4"];
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
-const MODEL_COUNT_OPTIONS = ["4", "8", "12"];
+const MODEL_COUNT_OPTIONS = ["1", "2", "4"];
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
@@ -528,6 +528,10 @@ export function ImageWorkbenchPage({
const product = products.find((item) => item.id === productId) || products[0];
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
const [ratio, setRatio] = useState(meta.ratio);
+ // 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
+ const [ratioManual, setRatioManual] = useState(false);
+ const [ratioW, setRatioW] = useState("");
+ const [ratioH, setRatioH] = useState("");
const [style, setStyle] = useState("auto");
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
@@ -613,6 +617,9 @@ export function ImageWorkbenchPage({
if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置 prompt 与默认比例
setRatio(meta.ratio);
+ setRatioManual(false);
+ setRatioW("");
+ setRatioH("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]);
@@ -1323,11 +1330,11 @@ export function ImageWorkbenchPage({
{product?.title || "未选择 · 请在左侧商品空间选一个"}
- {/* P0④:商品库全屏选择器入口(多选商品,各起一批) */}
- */}
{searchOpen && (
@@ -1402,9 +1409,8 @@ export function ImageWorkbenchPage({
{item.name.slice(0, 4)}
)}
+ {item.name}
- {item.name}
- // 真人模特
);
})
@@ -1423,9 +1429,8 @@ export function ImageWorkbenchPage({
{item.name}
+
{item.name}
- {item.name}
- {item.tag}
))}
@@ -1481,13 +1486,52 @@ export function ImageWorkbenchPage({
setRatio(value)}
+ className={`opt ${!ratioManual && ratio === value ? "active" : ""}`}
+ onClick={() => { setRatioManual(false); setRatio(value); }}
>
{value}
))}
+ {
+ setRatioManual(true);
+ if (ratioW && ratioH) setRatio(`${ratioW}:${ratioH}`);
+ }}
+ >
+ 手动输入
+
+ {ratioManual && (
+
+ {
+ const w = event.target.value;
+ setRatioW(w);
+ if (w && ratioH) setRatio(`${w}:${ratioH}`);
+ }}
+ />
+ :
+ {
+ const h = event.target.value;
+ setRatioH(h);
+ if (ratioW && h) setRatio(`${ratioW}:${h}`);
+ }}
+ />
+
+ )}
)}
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx
index e226d6b..899b026 100644
--- a/core/frontend/src/routes/products.tsx
+++ b/core/frontend/src/routes/products.tsx
@@ -10,6 +10,8 @@ import { SkeletonGrid } from "../components/loading";
import { api } from "../api";
const PROD_PAGE_SIZE = 10;
+// 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行)
+const MAT_PAGE_SIZE = 12;
import type { Asset, Product, ProductMaterials, Project } from "../types";
import type { NavigateFn, Page } from "./route-config";
import "../product-create-page.css";
@@ -621,13 +623,8 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const [triAdoptedId, setTriAdoptedId] = useState("");
const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0];
const triUrl = triViewing?.url || "";
- // 素材 tab 筛选 / 排序 / 分页 / 视图
- const [openFilter, setOpenFilter] = useState<"" | "type" | "status" | "sort">("");
- const [typeFilter, setTypeFilter] = useState("");
- const [statusFilter, setStatusFilter] = useState<"" | "pass" | "fail" | "pending">("");
- const [assetView, setAssetView] = useState<"grid" | "list">("grid");
- const [assetSortDesc, setAssetSortDesc] = useState(true);
- const [assetLimit, setAssetLimit] = useState(12);
+ // AI 素材 grid 当前页(与全站列表一致:整体翻页)
+ const [matPage, setMatPage] = useState(1);
const [videoSortDesc, setVideoSortDesc] = useState(true);
// 素材审核状态为只读(直接反映 review_status),不再有用户点击覆盖
// 采用三视图后,本地 prepend 的新素材卡(供「采用版本联动 + prepend 新卡」即时呈现,不等服务端回灌)
@@ -665,19 +662,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
if (!alive) return;
setProductAssets(assetsRes?.results ?? []);
setMaterials(matRes);
+ setMatPage(1); // 切商品 / 刷新素材后回到第 1 页
}).finally(() => { if (alive) setAssetsLoading(false); });
return () => { alive = false; };
}, [product?.id, assetReload]);
- useEffect(() => {
- if (!openFilter) return;
- const close = (event: MouseEvent) => {
- if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenFilter("");
- };
- document.addEventListener("click", close);
- return () => document.removeEventListener("click", close);
- }, [openFilter]);
-
async function onPickProductImage(event: ChangeEvent) {
const file = event.target.files?.[0];
event.target.value = "";
@@ -757,29 +746,27 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
// 采用三视图后本地 prepend 的新卡:置顶且与服务端列表去重(服务端回灌同 id 后即不再重复)
const serverIds = new Set(serverImageAssets.map((a) => a.id));
const allImageAssets = [...adoptedAssets.filter((a) => !serverIds.has(a.id)), ...serverImageAssets];
- // 联动采用状态前的「全部素材基线」(供 adoptTriView 找出该商品所有三视图)
- const allAssetsBase = allImageAssets;
- // 单个素材当前显示状态:本地覆盖(点击循环 / 采用联动)优先,否则回退真实 review_status 映射
- const statusOf = (asset: Asset): PdAssetStatus => pdBaseStatus(asset);
- // 类型筛选选项(当前素材里真实存在的 category)
- const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
- // 全集计数(给状态筛选下拉做计数前缀,不随当前筛选变化)
- const filteredAssets = allImageAssets
- .filter((asset) => !typeFilter || asset.category === typeFilter)
- .filter((asset) => !statusFilter || statusOf(asset) === statusFilter)
- .slice()
- .sort((a, b) => {
- const cmp = (b.created_at || "").localeCompare(a.created_at || "");
- return assetSortDesc ? cmp : -cmp;
- });
- const assetCount = filteredAssets.length;
- const imageAssets = filteredAssets.slice(0, assetLimit);
// 结构化素材总数(角色 + 商品图 + 图片成品 + 项目包内素材),用于头部计数 / 空态判断
const matTotal = materials
? materials.roles.length + materials.product_images.length + materials.image_products.length + materials.project_packs.reduce((s, p) => s + p.items.length, 0)
: 0;
- const hasActiveAssetFilter = Boolean(typeFilter || statusFilter);
- const resetAssetFilters = () => { setTypeFilter(""); setStatusFilter(""); setAssetLimit(12); setOpenFilter(""); };
+ // 拍平 4 类素材为单序列(角色 → 商品图 → 图片成品 → 视频项目包),整体翻页(与全站列表统一 Pager 一致)
+ type MatItem =
+ | { kind: "role"; role: ProductMaterials["roles"][number] }
+ | { kind: "product_image"; asset: Asset }
+ | { kind: "image_product"; asset: Asset }
+ | { kind: "pack"; pack: ProductMaterials["project_packs"][number] };
+ const matItems: MatItem[] = materials
+ ? [
+ ...materials.roles.map((role) => ({ kind: "role", role } as MatItem)),
+ ...materials.product_images.map((asset) => ({ kind: "product_image", asset } as MatItem)),
+ ...materials.image_products.map((asset) => ({ kind: "image_product", asset } as MatItem)),
+ ...materials.project_packs.map((pack) => ({ kind: "pack", pack } as MatItem)),
+ ]
+ : [];
+ const matTotalPages = Math.max(1, Math.ceil(matItems.length / MAT_PAGE_SIZE));
+ const matCurPage = Math.min(matPage, matTotalPages);
+ const matPageItems = matItems.slice((matCurPage - 1) * MAT_PAGE_SIZE, matCurPage * MAT_PAGE_SIZE);
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序)
const videoProjects = [...projects].sort((a, b) => {
const cmp = (b.updated_at || "").localeCompare(a.updated_at || "");
@@ -1075,68 +1062,62 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
navigate("imageOptimize", { productId: product.id })}>去生成素材
) : (
+ <>
- {/* 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套 */}
- {materials!.roles.map((role) => {
- const cover = pdAssetPreview(role.portrait);
- return (
-
setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })}
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}>
-
- {cover ?

:
角色}
-
角色
- {role.triview &&
含三视图}
+ {matPageItems.map((item) => {
+ // 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套
+ if (item.kind === "role") {
+ const role = item.role;
+ const cover = pdAssetPreview(role.portrait);
+ return (
+
setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })}
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}>
+
+ {cover ?

:
角色}
+
角色
+ {role.triview &&
含三视图}
+
+
{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}
-
{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}
-
- );
- })}
- {/* 商品图:平铺 */}
- {materials!.product_images.map((a) => {
+ );
+ }
+ // 视频项目包:场景+分镜+视频素材按项目打包,点开看整组
+ if (item.kind === "pack") {
+ const pack = item.pack;
+ const first = pack.items[0];
+ const cover = pdAssetPreview(first);
+ const isVid = first?.asset_type === "video";
+ return (
+
setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })}
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}>
+
+ {isVid && cover ?
: cover ?

:
视频项目}
+
视频项目
+
{pack.items.length}
+
+
{pack.project_name}
+
+ );
+ }
+ // 商品图 / 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签
+ const a = item.asset;
const u = pdAssetPreview(a);
+ const label = item.kind === "product_image" ? "商品图" : pdAssetTypeLabel(a);
return (
setPreview({ src: u, name: a.name }) : undefined}>
- {u ?

:
商品图}
-
商品图
+ {u ?

:
{label}}
+
{label}
{(a.created_at || "").slice(0, 10)}
);
})}
- {/* 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签 */}
- {materials!.image_products.map((a) => {
- const u = pdAssetPreview(a);
- return (
-
-
setPreview({ src: u, name: a.name }) : undefined}>
- {u ?

:
{pdAssetTypeLabel(a)}}
-
{pdAssetTypeLabel(a)}
-
-
{(a.created_at || "").slice(0, 10)}
-
- );
- })}
- {/* 视频项目包:场景+分镜+视频素材按项目打包,点开看整组 */}
- {materials!.project_packs.map((pack) => {
- const first = pack.items[0];
- const cover = pdAssetPreview(first);
- const isVid = first?.asset_type === "video";
- return (
-
setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })}
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}>
-
- {isVid && cover ?
: cover ?

:
视频项目}
-
视频项目
-
{pack.items.length}
-
-
{pack.project_name}
-
- );
- })}
+
+ >
)}