大量优化 模型改成gpt6
Deploy dev / deploy (push) Successful in 41s

This commit is contained in:
Azmat@qq.com
2026-09-28 18:15:08 +08:00
parent a84431c9c5
commit 4421706290
14 changed files with 1878 additions and 540 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import type {
AITask,
Asset,
BillingSummary,
CoverProductInfo,
BillingTrend,
ExportPoll,
Ledger,
@@ -823,7 +824,7 @@ export function App() {
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) {
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: import("./types").CreationRef[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) {
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
+142 -9
View File
@@ -2676,7 +2676,49 @@
}
.yz-image .generator-settings .control-heading,
.yz-image .generator-settings .field-group:last-child { grid-column: 1 / -1; }
.yz-image .platform-generator-settings .field-group { grid-column: 1 / -1; }
/* 平台套图(cover):无「选择平台」—— 选择商品独占一行(商品卡横排),生成设置内为商品信息表单 */
.yz-image .asset-pair-grid.single { grid-template-columns: minmax(0, 1fr); }
.yz-image .asset-pair-grid.single .product-choice-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.yz-image .asset-pair-grid.single .product-library-choice { min-height: 72px; }
.yz-image .cover-generator-settings .field-group { grid-column: 1 / -1; }
.yz-image .cover-generator-settings .field-group.half { grid-column: auto; }
.yz-image .cover-generator-settings .control-heading { margin-bottom: 4px; }
.yz-image .cover-info-reset {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 5px;
height: 22px;
padding: 0;
border: 0;
color: var(--muted);
background: transparent;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.yz-image .cover-info-reset:hover { color: var(--klein); }
.yz-image .image-prompt.cover-info-textarea { min-height: 88px; height: 88px; font-size: 13px; line-height: 1.6; }
.yz-image .cover-info-input {
width: 100%;
height: 40px;
padding: 0 13px;
border: 1px solid rgba(34, 42, 54, 0.11);
border-radius: 10px;
outline: 0;
color: var(--text);
background: rgba(34, 42, 54, 0.03);
font: inherit;
font-size: 13px;
}
.yz-image .cover-info-input:focus {
border-color: rgba(0, 47, 167, 0.48);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
}
.yz-image .cover-info-input::placeholder,
.yz-image .cover-info-textarea::placeholder { color: rgba(74, 80, 89, 0.55); }
.yz-image .model-line .cover-info-hint { color: var(--heat, #c2410c); }
.yz-image .control-heading {
display: flex;
align-items: center;
@@ -2975,20 +3017,20 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 0 20px;
gap: 12px;
padding: 0 14px;
position: relative;
z-index: 5;
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
}
.yz-image .result-topbar strong { font-size: 14px; }
.yz-image .result-tags { display: flex; align-items: center; gap: 8px; }
.yz-image .result-topbar strong { flex: 0 0 auto; font-size: 14px; white-space: nowrap; }
.yz-image .result-tags { display: flex; align-items: center; min-width: 0; }
.yz-image .result-tags span {
padding: 6px 9px;
border-radius: 999px;
overflow: hidden;
color: var(--muted);
background: rgba(34, 42, 54, 0.055);
font-size: 10px;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.yz-image .result-canvas {
position: relative;
@@ -3231,6 +3273,89 @@
font-size: 14px;
line-height: 1.6;
}
.yz-image .studio-prompt-wrap {
position: relative;
min-width: 0;
min-height: 96px;
}
.yz-image .studio-prompt.rich-mention-editor {
min-height: 96px;
max-height: 160px;
overflow-y: auto;
}
.yz-image .studio-mention-menu {
position: absolute;
z-index: 40;
bottom: calc(100% + 8px);
left: 0;
width: min(320px, 100%);
max-height: 320px;
overflow-y: auto;
padding: 8px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
.yz-image .studio-mention-head,
.yz-image .studio-mention-group > span {
display: block;
padding: 4px 8px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .04em;
}
.yz-image .studio-mention-list { display: grid; gap: 6px; }
.yz-image .studio-mention-group + .studio-mention-group {
padding-top: 6px;
border-top: 1px solid var(--border-faint);
}
.yz-image .studio-mention-group button {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: var(--r-md);
color: var(--accent-black);
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.yz-image .studio-mention-group button:hover { background: var(--black-alpha-4); }
.yz-image .studio-mention-group img,
.yz-image .studio-mention-group i {
width: 28px;
height: 28px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: var(--r-md);
color: var(--heat);
background: var(--heat-12);
font-family: var(--font-mono);
font-size: 11px;
font-style: normal;
}
.yz-image .studio-mention-group img { object-fit: cover; }
.yz-image .studio-mention-group b {
overflow: hidden;
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.yz-image .studio-mention-state {
display: block;
padding: 12px 8px;
color: var(--black-alpha-48);
font-size: 12px;
}
.yz-image .image-composer-footer {
display: flex;
align-items: center;
@@ -3349,8 +3474,16 @@
.yz-image .result-topbar-tools {
display: flex;
align-items: center;
min-width: 0;
margin-left: auto;
gap: 8px;
}
.yz-image .tb-search-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.yz-image .result-topbar .search-btn {
width: 32px;
height: 32px;
+2 -1
View File
@@ -1,4 +1,5 @@
import type {
CoverProductInfo,
AdminIntegrity,
AdminLedger,
AdminModel,
@@ -1007,7 +1008,7 @@ export const api = {
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
// 带 batch_id(重跑/补图)则任务归回原批次;响应回传本批 batch_id 供前端存进批次卡。
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string }) {
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: CreationRef[]; batch_id?: string; retry_of_task_id?: string }) {
return request<{ conversation_id: string; batch_id?: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
+1 -1
View File
@@ -29,7 +29,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
{ id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "按商品信息生成主图、卖点图、细节图与场景图", page: "platformCover", icon: "images" },
{ id: "image-optimize", group: "常用动作", label: "自由创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
];
+227 -2
View File
@@ -909,6 +909,52 @@ html[data-theme="dark"] .omni-upload-menu button > svg {
color: #8eb0ff;
}
/* 全能创作会话的 @ 引用面板是独立组件,不能沿用旧 .omni-mention-menu 的浅色 hover。 */
html[data-theme="dark"] .omni-session-mention-menu {
border-color: var(--border-faint);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats {
border-color: var(--border-faint);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button {
color: var(--black-alpha-56);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button svg {
color: var(--black-alpha-48);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button:hover,
html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button.is-on {
color: var(--heat);
background: var(--heat-12);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button.is-on svg {
color: var(--heat);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-list > strong,
html[data-theme="dark"] .omni-session-mention-menu .omni-at-loading {
color: var(--black-alpha-48);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-loading .omni-send-spinner {
border-color: var(--heat-20);
border-top-color: var(--heat);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button {
color: var(--accent-black);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button:hover,
html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button:focus-visible {
color: var(--heat);
background: var(--heat-12);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button small {
color: var(--black-alpha-48);
}
html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button img {
background: var(--background-lighter);
}
html[data-theme="dark"] .omni-slot-tag {
color: #0f1115;
background: #e8eaed;
@@ -1072,6 +1118,65 @@ html[data-theme="dark"] .omni-session-page .omni-process-card {
color: var(--accent-black);
box-shadow: none;
}
/* 确认生成卡的参数条仍复用浅色页的控件基样式;在深色会话里单独收回到工作面的层级。
禁用态保留可读性,但不再出现白色输入框。 */
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn,
html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger {
border-color: var(--border-muted);
color: var(--accent-black);
background: var(--background-lighter);
}
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn:hover:not(:disabled),
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select.open .rs-select-btn,
html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger:hover:not(:disabled),
html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger[aria-expanded="true"] {
border-color: var(--heat-40);
color: var(--accent-black);
background: var(--surface-raised);
box-shadow: 0 0 0 3px var(--heat-8);
}
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn:disabled,
html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger:disabled {
border-color: var(--border-faint);
color: var(--black-alpha-48);
background: var(--black-alpha-4);
opacity: 1;
}
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn svg,
html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger svg {
color: var(--black-alpha-56);
}
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select.open .rs-select-btn svg,
html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger[aria-expanded="true"] svg {
color: var(--heat);
}
/* 时长面板通过 portal 挂到 body,不能依赖会话页父级选择器。 */
html[data-theme="dark"] .omni-duration-menu {
border-color: var(--border-faint);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
html[data-theme="dark"] .omni-duration-title {
color: var(--black-alpha-56);
}
html[data-theme="dark"] .omni-duration-modes button,
html[data-theme="dark"] .omni-duration-values button {
color: var(--black-alpha-72);
background: var(--background-lighter);
}
html[data-theme="dark"] .omni-duration-modes button,
html[data-theme="dark"] .omni-duration-values {
border-color: var(--border-faint);
}
html[data-theme="dark"] .omni-duration-modes button.active,
html[data-theme="dark"] .omni-duration-values button:hover,
html[data-theme="dark"] .omni-duration-values button.active {
border-color: var(--heat-40);
color: var(--heat);
background: var(--heat-12);
}
html[data-theme="dark"] .omni-session-page .omni-chat-row.agent .omni-chat-bubble {
border-color: var(--border-faint);
border-bottom-left-radius: var(--r-sm);
@@ -1377,6 +1482,32 @@ html[data-theme="dark"] .yz-image .image-prompt:focus {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 3px rgba(61, 107, 255, 0.14);
}
/* 平台套图 · 商品信息表单(核心卖点 / 作用 / 人群 / 规格 / 补充) */
html[data-theme="dark"] .yz-image .cover-info-input {
border-color: rgba(255, 255, 255, 0.12);
color: #e8eaed;
background: rgba(255, 255, 255, 0.04);
}
html[data-theme="dark"] .yz-image .cover-info-input:focus {
border-color: rgba(61, 107, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 3px rgba(61, 107, 255, 0.14);
}
html[data-theme="dark"] .yz-image .cover-info-input::placeholder,
html[data-theme="dark"] .yz-image .cover-info-textarea::placeholder {
color: rgba(232, 234, 237, 0.42);
}
html[data-theme="dark"] .yz-image .cover-info-reset {
border: 0;
color: var(--black-alpha-56);
background: transparent;
}
html[data-theme="dark"] .yz-image .cover-info-reset:hover {
color: var(--heat);
}
html[data-theme="dark"] .yz-image .model-line .cover-info-hint {
color: #fbbf77;
}
html[data-theme="dark"] .yz-image .asset-pair-grid > .control-section + .control-section,
html[data-theme="dark"] .yz-image .generator-settings,
html[data-theme="dark"] .yz-image .result-topbar,
@@ -1400,8 +1531,58 @@ html[data-theme="dark"] .yz-image .platform-choice.active .choice-check::after {
background: #fff;
}
html[data-theme="dark"] .yz-image .result-tags span {
color: rgba(232, 234, 237, 0.62);
background: rgba(255, 255, 255, 0.06);
color: var(--black-alpha-56);
background: transparent;
}
/* 套图预览工具:不能继承浅色态的白色搜索方块;模型选择和展开菜单保持同一暗面层级。 */
html[data-theme="dark"] .yz-image .result-topbar .search-btn,
html[data-theme="dark"] .yz-image .model-line .ic-param-btn,
html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn {
border: 1px solid var(--border-faint);
color: var(--black-alpha-64);
background: var(--background-lighter);
}
html[data-theme="dark"] .yz-image .result-topbar .search-btn:hover,
html[data-theme="dark"] .yz-image .result-topbar .search-btn.active,
html[data-theme="dark"] .yz-image .model-line .ic-param-btn:hover,
html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn:hover {
border-color: var(--heat-20);
color: var(--heat);
background: var(--heat-12);
}
html[data-theme="dark"] .yz-image .model-line .ic-param-btn .lbl-mono,
html[data-theme="dark"] .yz-image .model-line .ic-param-btn svg,
html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn .lbl-mono,
html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn svg {
color: var(--black-alpha-48);
}
html[data-theme="dark"] .yz-image .model-line .ic-param-btn span:not(.lbl-mono),
html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn span:not(.lbl-mono) {
color: var(--accent-black);
}
html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn,
html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn {
border-color: var(--heat-20);
color: var(--heat);
background: var(--heat-12);
}
html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn .lbl-mono,
html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn span:not(.lbl-mono),
html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn svg,
html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn .lbl-mono,
html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn span:not(.lbl-mono),
html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn svg {
color: var(--heat);
}
html[data-theme="dark"] .yz-image .model-line .ic-param-menu {
border-color: var(--border-faint);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
html[data-theme="dark"] .yz-image .image-composer-options .ic-param-menu {
border-color: var(--border-faint);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
html[data-theme="dark"] .yz-image .result-empty-icon {
border-color: rgba(255, 255, 255, 0.10);
@@ -2355,6 +2536,50 @@ html[data-theme="dark"] .fc-page .fc-composer {
background: #1a1e26;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
/* 底部参数条不能沿用浅色态的深灰字:标签和值需保持明确层级,展开菜单同一暗面。 */
html[data-theme="dark"] .fc-page .fc-chip {
border: 1px solid var(--border-faint);
color: var(--black-alpha-56);
background: var(--background-lighter);
}
html[data-theme="dark"] .fc-page .fc-chip strong {
color: var(--accent-black);
}
html[data-theme="dark"] .fc-page .fc-chip svg {
color: var(--black-alpha-48);
}
html[data-theme="dark"] .fc-page .fc-chip:hover,
html[data-theme="dark"] .fc-page .fc-dd.open .fc-chip {
border-color: var(--heat-20);
color: var(--heat);
background: var(--heat-12);
}
html[data-theme="dark"] .fc-page .fc-dd.open .fc-chip strong,
html[data-theme="dark"] .fc-page .fc-dd.open .fc-chip svg {
color: var(--heat);
}
html[data-theme="dark"] .fc-page .fc-dd-menu {
border-color: var(--border-faint);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
html[data-theme="dark"] .fc-page .fc-dd-item:hover {
background: var(--heat-12);
}
html[data-theme="dark"] .fc-page .fc-dd-item.selected {
background: var(--heat-16);
}
html[data-theme="dark"] .fc-page .fc-dd-item .ti {
color: var(--accent-black);
}
html[data-theme="dark"] .fc-page .fc-dd-item .de,
html[data-theme="dark"] .fc-page .fc-seed-hint {
color: var(--black-alpha-48);
}
html[data-theme="dark"] .fc-page .fc-dd-item.disabled .ti,
html[data-theme="dark"] .fc-page .fc-dd-item.disabled .de {
color: var(--black-alpha-32);
}
html[data-theme="dark"] .fc-page .fc-tag {
color: rgba(232, 234, 237, 0.82);
background: rgba(255, 255, 255, 0.08);
+129 -15
View File
@@ -418,10 +418,10 @@
.omni-elicit-card {
width: min(760px, calc(100% - 44px));
margin: 0 0 24px 44px;
border: 1px solid rgba(34, 42, 54, .09);
border-radius: 16px;
border: 0;
border-radius: var(--r-md);
background: var(--surface);
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
box-shadow: inset 0 0 0 1px var(--border-faint);
animation: omniMessageIn 220ms ease both;
}
@@ -527,7 +527,7 @@
.omni-video-plan-card {
overflow: hidden;
border-top: 3px solid var(--black);
border-top: 3px solid var(--heat);
}
.omni-video-plan-head span {
@@ -542,7 +542,7 @@
.omni-plan-section {
padding: 15px 17px;
border-bottom: 1px solid rgba(34, 42, 54, .07);
box-shadow: inset 0 -1px 0 var(--border-faint);
}
.omni-plan-section:last-child {
@@ -564,9 +564,9 @@
.omni-plan-points span {
padding: 10px;
border-radius: 9px;
color: #525965;
background: #f6f8fa;
border-radius: var(--r-md);
color: var(--black-alpha-72);
background: var(--background-lighter);
font-size: 13px;
line-height: 1.65;
}
@@ -596,7 +596,7 @@
.omni-plan-timeline {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
@@ -604,10 +604,11 @@
position: relative;
min-height: 78px;
padding: 11px 10px;
border: 1px solid rgba(34, 42, 54, .08);
border-radius: 10px;
color: #5b626d;
border: 0;
border-radius: var(--r-md);
color: var(--black-alpha-72);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--border-faint);
font-size: 13px;
line-height: 1.65;
}
@@ -620,6 +621,53 @@
line-height: 1.45;
}
.omni-plan-overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.omni-plan-overview-grid span,
.omni-plan-timeline-item em {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
font-style: normal;
}
.omni-plan-overview-grid span {
padding: 10px;
border-radius: var(--r-md);
background: var(--background-lighter);
}
.omni-plan-overview-grid b,
.omni-plan-timeline-item i {
color: var(--black-alpha-56);
font-size: 11px;
font-style: normal;
font-weight: 500;
}
.omni-plan-overview-grid em {
color: var(--accent-black);
font-size: 13px;
font-style: normal;
line-height: 1.55;
}
.omni-plan-timeline-item {
display: flex;
flex-direction: column;
gap: 9px;
}
.omni-plan-timeline-item em {
padding-top: 8px;
box-shadow: inset 0 1px 0 var(--border-faint);
}
.omni-plan-summary {
display: flex;
align-items: center;
@@ -1164,12 +1212,21 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.omni-at-list button strong {
overflow: hidden;
color: inherit;
font-size: inherit;
font-weight: 500;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-at-list button small {
color: #8b919a;
font-size: 12px;
display: none;
}
.omni-session-send {
@@ -1446,6 +1503,8 @@
.omni-strategy-grid,
.omni-plan-points,
.omni-plan-timeline,
.omni-plan-overview-grid,
.omni-product-brief-list,
.omni-direction-list {
grid-template-columns: 1fr;
}
@@ -1492,6 +1551,61 @@
padding: 14px 18px 16px;
}
.omni-product-brief-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.omni-product-brief-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px;
border-radius: var(--r-md);
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.omni-product-brief-item > span {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.omni-product-brief-item b {
color: var(--accent-black);
font-size: 12px;
font-weight: 600;
}
.omni-product-brief-item em {
overflow: hidden;
color: var(--black-alpha-56);
font-size: 11px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-product-brief-item > i {
flex: 0 0 auto;
padding: 3px 6px;
border-radius: 999px;
color: var(--black-alpha-56);
background: var(--surface);
font-size: 10px;
font-style: normal;
font-weight: 500;
}
.omni-product-brief-item.is-ready > i {
color: var(--heat);
background: var(--heat-12);
}
/* 卖点确认在策略和脚本之前出现:输入真实卖点,或把筛选权交给系统。 */
.omni-selling-point-card {
box-sizing: border-box;
+343 -220
View File
@@ -20,14 +20,13 @@ import {
Plus,
RefreshCw,
Search,
SlidersHorizontal,
Sparkles,
Trash2,
Users,
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import type { AITask, Asset, CoverProductInfo, CreationRef, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import { pointsPerImageFromCatalog } from "../components/free-create/constants";
import { api } from "../api";
import { useFileDrop } from "../components/use-file-drop";
@@ -35,6 +34,7 @@ import { imageModelPickerOptions } from "../model-display";
import { ModelLibrary } from "../components/model-library";
import { SkeletonRows, SystemLoading } from "../components/loading";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { RichMentionEditor, type RichMentionEditorHandle } from "../components/rich-mention-editor";
import { Pager } from "../components/pager";
import { useViewMode } from "../components/use-view-mode";
import type { Page } from "./route-config";
@@ -454,11 +454,11 @@ const MODE_META: Record<
},
cover: {
title: "平台套图",
desc: "根据平台规范生成主图、卖点图、细节图与场景图",
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
desc: "根据商品信息和商品图生成主图、卖点图、细节图与场景图",
// 通用电商主图规范:默认 1:1(不再按平台切换比例)
ratio: "1:1",
// YYX#4:只出平台头图,去掉「详情排版」
promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰`
// 仅作兜底文案;实际提交的 prompt 由「商品信息」拼出(composeCoverPrompt)
promptTemplate: (title) => `${title},电商主图套图,统一视觉,商品主体清晰`
}
};
@@ -479,38 +479,73 @@ const IMAGE_SUGGESTIONS = [
{ label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" }
];
/* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg;
logo 留作图加载失败时的兜底字符,img 为真 logo) */
const PLATFORM_OPTIONS = [
{ id: "dy", name: "抖音电商", logo: "抖", img: "/assets/svg/icon-platform-douyin.svg" },
{ id: "tb", name: "淘宝", logo: "淘", img: "/assets/svg/icon-platform-taobao.svg" },
{ id: "tm", name: "天猫", logo: "猫", img: "/assets/svg/icon-platform-tmall.svg" },
{ id: "jd", name: "京东", logo: "京", img: "/assets/svg/icon-platform-jd.svg" },
{ id: "pdd", name: "拼多多", logo: "拼", img: "/assets/svg/icon-platform-pdd.svg" },
{ id: "xhs", name: "小红书", logo: "红", img: "/assets/svg/icon-platform-xiaohongshu.svg" },
{ id: "ks", name: "快手", logo: "快", img: "/assets/svg/icon-platform-kuaishou.svg" },
{ id: "sph", name: "视频号", logo: "视", img: "/assets/svg/icon-platform-wechat-video.svg" },
{ id: "amz", name: "亚马逊", logo: "a", img: "/assets/svg/icon-platform-amazon.svg" },
{ id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" }
];
/* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */
/* 前端平台 id(dy/tb…) ↔ 后端规范化 platform_id。平台套图已不再选平台(走通用电商主图规范),
这里只保留给旧记录:恢复旧批次时还原平台 key,重跑旧批次时原样带回 platform_id(后端仍兼容)。 */
const PLATFORM_ID_MAP: Record<string, string> = {
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
};
/* YYX#row10:平台 logo —— 优先真 logo 图(public/assets/svg),加载失败回退到品牌色块+字符。
className 默认 p-logo(平台卡/筛选弹窗),分组头传 cg-logo。 */
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
const [err, setErr] = useState(false);
return (
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
{p.img && !err
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
: p.logo}
</span>
);
/* 平台套图 · 商品信息(替代原「画面要求」):用户填写卖点 / 作用 / 人群 / 规格 / 补充,
默认从商品资料(selling_points / description / target_audience / specs)带入。 */
type CoverInfoDraft = { sellingPoints: string; effect: string; audience: string; specs: string; notes: string };
function formatProductSpecs(specs?: Record<string, unknown>): string {
if (!specs || typeof specs !== "object") return "";
return Object.entries(specs)
.filter(([, value]) => typeof value === "string" || typeof value === "number")
.map(([key, value]) => `${key}:${String(value).trim()}`)
.filter((line) => !line.endsWith(":"))
.slice(0, 6)
.join(";");
}
function coverInfoFromProduct(p?: Product): CoverInfoDraft {
const points = [...(p?.selling_points || [])]
.sort((a, b) => a.sort_order - b.sort_order)
.map((sp) => {
const title = (sp.title || "").trim();
const detail = (sp.detail || "").trim();
if (!title) return detail.slice(0, 40);
return detail && detail.length <= 40 ? `${title}:${detail}` : title;
})
.filter(Boolean);
return {
sellingPoints: points.join("\n"),
effect: (p?.description || "").trim().slice(0, 300),
audience: (p?.target_audience || "").trim(),
specs: formatProductSpecs(p?.specs),
notes: ""
};
}
/* 与后端 _split_selling_points 同口径:按换行 / 分号拆,只有一行时再按顿号 / 逗号拆,最多 6 条 */
function splitSellingPoints(text: string): string[] {
const lines = text.split(/[\n\r;;]+/).map((x) => x.replace(/^\s*(?:[-•·*]+|\d+\s*[.、))])\s*/, "").trim()).filter(Boolean);
const parts = lines.length <= 1 ? (lines[0] || text).split(/[、,,]+/).map((x) => x.trim()).filter(Boolean) : lines;
return parts.slice(0, 6);
}
function coverInfoPayload(d: CoverInfoDraft): CoverProductInfo {
const out: CoverProductInfo = {};
if (d.sellingPoints.trim()) out.selling_points = d.sellingPoints.trim();
if (d.effect.trim()) out.effect = d.effect.trim();
if (d.audience.trim()) out.audience = d.audience.trim();
if (d.specs.trim()) out.specs = d.specs.trim();
if (d.notes.trim()) out.notes = d.notes.trim();
return out;
}
/* 可读摘要:作为批次 prompt(批次头展示 / 搜索 / 旧客户端兼容),结构化信息另走 product_info */
function composeCoverPrompt(title: string, d: CoverInfoDraft): string {
const parts: string[] = [];
const points = splitSellingPoints(d.sellingPoints);
if (points.length) parts.push(`核心卖点:${points.join("、")}`);
if (d.effect.trim()) parts.push(`商品作用:${d.effect.trim()}`);
if (d.audience.trim()) parts.push(`适用人群:${d.audience.trim()}`);
if (d.specs.trim()) parts.push(`规格:${d.specs.trim()}`);
if (d.notes.trim()) parts.push(`补充:${d.notes.trim()}`);
return `${title}电商主图 · ${parts.join(";")}`;
}
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。
@@ -530,10 +565,14 @@ type GenBatch = {
modelId?: string;
/** 该批次选中的模特展示名(导航头显示) */
modelName?: string;
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
/** 旧平台套图记录的平台 id 列表(已不再选平台;仅用于重跑旧批次时原样带回) */
platformIds?: string[];
/** 平台套图:本批提交的商品信息(重跑 / 补图沿用) */
productInfo?: CoverProductInfo;
/** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */
refs?: { name: string; url: string; assetId?: string }[];
/** 图片创作 @ 引用的结构化实体;重跑时仍按实体取事实和参考图。 */
mentions?: CreationRef[];
/** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */
backendBatchId?: string;
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
@@ -582,7 +621,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: CreationRef[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */
imageProductId?: string;
@@ -619,6 +658,15 @@ export function ImageWorkbenchPage({
conversationScopeKeyRef.current = conversationScopeKey;
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
// 平台套图:商品信息草稿(切换商品时从商品资料重新带入;同一商品的资料刷新不覆盖用户编辑)
const [coverInfo, setCoverInfo] = useState<CoverInfoDraft>(() => coverInfoFromProduct(product));
useEffect(() => {
if (mode === "cover") setCoverInfo(coverInfoFromProduct(product));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, product?.id]);
const updateCoverInfo = (key: keyof CoverInfoDraft, value: string) => setCoverInfo((prev) => ({ ...prev, [key]: value }));
const coverInfoReady = !!(coverInfo.sellingPoints.trim() || coverInfo.effect.trim());
const coverSellingCount = splitSellingPoints(coverInfo.sellingPoints).length;
const [ratio, setRatio] = useState(meta.ratio);
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
const [ratioManual, setRatioManual] = useState(false);
@@ -686,17 +734,23 @@ export function ImageWorkbenchPage({
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 图片创作 @ 引用保留实体 id;后端再回库取卖点和真实参考图,不依赖名字匹配。
const [mentionRefs, setMentionRefs] = useState<CreationRef[]>([]);
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
const [mentionTypeLabels, setMentionTypeLabels] = useState<Record<string, string>>({});
const mentionEditorRef = useRef<RichMentionEditorHandle>(null);
const mentionMenuRef = useRef<HTMLDivElement>(null);
// 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22)
const resultsEndRef = useRef<HTMLDivElement | null>(null);
// 生成结果图片放大预览
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
// 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用;
// row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除)
// 平台筛选弹窗已随「选择平台」一起移除。
const [gridQuery, setGridQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
// YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛)
const [filterOpen, setFilterOpen] = useState(false);
const [platformFilter, setPlatformFilter] = useState<string[]>([]);
const visibleProducts = products.filter((item) => !(mode === "model" && isLocalLife(item)));
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
const productCoverUrl = (p: Product): string => {
@@ -718,6 +772,49 @@ export function ImageWorkbenchPage({
const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") });
const mentionName = (ref: CreationRef) => ref.name.split(" · ")[0].trim();
const openImageMentions = useCallback(async () => {
setMentionMenuOpen(true);
setMentionLoading(true);
try {
const result = await api.searchMentions({ limit: 6 });
setMentionResults(result.results);
setMentionTypeLabels(result.type_labels);
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "引用素材加载失败");
setMentionResults([]);
} finally {
setMentionLoading(false);
}
}, [onNotify]);
const selectImageMention = (ref: CreationRef) => {
setMentionRefs((prev) => prev.some((item) => item.type === ref.type && item.id === ref.id) ? prev : [...prev, ref]);
mentionEditorRef.current?.insertMention(ref, true);
setMentionMenuOpen(false);
};
const handleStudioPromptChange = (value: string) => {
setPrompt(value);
// 引用标签被用户删除时,同步解除结构化引用,避免看不见的素材仍参与生成。
setMentionRefs((prev) => prev.filter((ref) => value.includes(`@${mentionName(ref)}`)));
};
const mentionGroups = useMemo(() => {
const groups = new Map<string, CreationRef[]>();
mentionResults.forEach((ref) => groups.set(ref.type, [...(groups.get(ref.type) || []), ref]));
return Array.from(groups.entries());
}, [mentionResults]);
useEffect(() => {
if (!mentionMenuOpen) return;
const close = (event: MouseEvent) => {
if (!mentionMenuRef.current?.contains(event.target as Node)) setMentionMenuOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [mentionMenuOpen]);
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
const [priceMultiplier, setPriceMultiplier] = useState(1);
@@ -790,26 +887,17 @@ export function ImageWorkbenchPage({
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openMore]);
// YYX#12:点空白收起平台筛选弹窗
useEffect(() => {
if (!filterOpen) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".iw-filter-wrap")) setFilterOpen(false); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [filterOpen]);
const ratioVar = ratio.replace(":", " / ");
const candidateCount = Math.max(1, Number(count) || 4);
// PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。
// 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。
const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount;
// 平台套图已不再按平台拆批:每次提交一批 candidateCount 张,实扣 = candidateCount × 单价(三种模式同口径)。
const estimateCount = candidateCount;
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
const canGenerate =
prompt.trim().length > 0 &&
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
// 平台套图:必须选商品(商品图作参考)+ 至少填写核心卖点或商品作用之一
const canGenerate = mode === "cover"
? !!product?.id && coverInfoReady
: prompt.trim().length > 0 && (mode !== "model" || (!!product?.id && pickedIds.length > 0));
/* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、
工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect),
@@ -919,6 +1007,7 @@ export function ImageWorkbenchPage({
productId: first.product_id || undefined,
modelId: first.model_id || undefined,
platformIds: platformKey ? [platformKey] : undefined,
productInfo: first.product_info || undefined,
backendBatchId: first.batch_id || undefined,
taskIds: live.map((t) => t.id),
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
@@ -976,6 +1065,8 @@ export function ImageWorkbenchPage({
setActiveConvId(conv.id);
setBatches([]);
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
setMentionRefs([]);
setMentionMenuOpen(false);
setPickedIds([]);
} catch (err) {
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
@@ -1053,6 +1144,8 @@ export function ImageWorkbenchPage({
setActiveConvId("");
setConversations([]);
setBatches([]);
setMentionRefs([]);
setMentionMenuOpen(false);
setRenamingId("");
setConvError("");
void loadConversations();
@@ -1069,10 +1162,14 @@ export function ImageWorkbenchPage({
modelId?: string;
modelName?: string;
platformIds?: string[];
/** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */
/** 旧记录兼容:规范化平台 id(douyin/taobao…),仅重跑旧平台批次时带回 */
platformId?: string;
/** 平台套图:商品信息(卖点 / 作用 / 人群 / 规格 / 补充),透传后端注入主图提示词 */
productInfo?: CoverProductInfo;
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
refs?: { name: string; url: string; file?: File; assetId?: string }[];
/** 图片创作 @ 引用的实体;后端根据 id 解析为素材事实和参考图。 */
mentions?: CreationRef[];
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
reuseBatchId?: string;
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
@@ -1105,8 +1202,10 @@ export function ImageWorkbenchPage({
modelId: opts.modelId,
modelName: opts.modelName,
platformIds: opts.platformIds,
productInfo: opts.productInfo,
// 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId)
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId }))
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId })),
mentions: opts.mentions
};
setBatches((prev) => [...prev, newBatch]);
}
@@ -1135,7 +1234,7 @@ export function ImageWorkbenchPage({
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, product_info: opts.productInfo, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, mention_refs: opts.mentions, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
onSubmitted: (taskIds, submittedBatchId) => {
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
setBatches((prev) => prev.map((b) => {
@@ -1210,11 +1309,13 @@ export function ImageWorkbenchPage({
productTitle: mode === "image" ? imageProduct?.title : product?.title
};
if (mode === "cover") {
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
// 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束)
for (const pid of pickedIds) {
void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] });
}
// 不再选平台:一次提交一批,结构化商品信息走 product_info(后端注入电商主图提示词),
// prompt 为可读摘要(批次头展示 / 搜索用)。商品图参考链路不变(后端按 product_id 取商品图)。
void startBatch({
...base,
prompt: composeCoverPrompt(product?.title || "商品", coverInfo),
productInfo: coverInfoPayload(coverInfo)
});
return;
}
void startBatch({
@@ -1222,11 +1323,12 @@ export function ImageWorkbenchPage({
modelId: mode === "model" ? pickedIds[0] : undefined,
modelName: mode === "model" ? pickedModelName : undefined,
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
refs: mode === "image" && refImages.length ? refImages : undefined
refs: mode === "image" && refImages.length ? refImages : undefined,
mentions: mode === "image" && mentionRefs.length ? mentionRefs : undefined
});
// 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。
// 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。
if (mode === "image") { setRefImages([]); setPrompt(""); }
if (mode === "image") { setRefImages([]); setMentionRefs([]); setPrompt(""); }
// 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
}
@@ -1244,8 +1346,10 @@ export function ImageWorkbenchPage({
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
productInfo: src.productInfo,
// 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录
refs: src.refs,
mentions: src.mentions,
batchId: src.backendBatchId,
reuseBatchId: src.id
});
@@ -1321,6 +1425,7 @@ export function ImageWorkbenchPage({
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
productInfo: src.productInfo,
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
refs: src.refs,
batchId: src.backendBatchId,
@@ -1381,12 +1486,8 @@ export function ImageWorkbenchPage({
const q = gridQuery.trim().toLowerCase();
list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q));
}
// YYX#12:筛选弹窗按平台筛选已生成的图(选了哪些平台就只看哪些)
if (mode === "cover" && platformFilter.length) {
list = list.filter((b) => (b.platformIds || []).some((p) => platformFilter.includes(p)));
}
return list;
}, [batches, productId, mode, gridQuery, platformFilter]);
}, [batches, productId, mode, gridQuery]);
const hasResults = productBatches.length > 0;
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
@@ -1533,42 +1634,6 @@ export function ImageWorkbenchPage({
);
}
/* ── 平台套图 · 多平台分组结果(P0③)──
每个平台一个 section 头(平台 logo + 名 + 批次数),其下挂该平台的批次卡。 */
function renderCoverGrouped(list: GenBatch[]) {
// 按平台 id 分组;无平台标签的批次归到「未分类」组(兜底,正常不出现)
const groups: Array<{ pid: string; batches: GenBatch[] }> = [];
for (const batch of list) {
const pid = batch.platformIds?.[0] || "_";
let g = groups.find((x) => x.pid === pid);
if (!g) { g = { pid, batches: [] }; groups.push(g); }
g.batches.push(batch);
}
return (
<>
{groups.map((g) => {
const plat = PLATFORM_OPTIONS.find((p) => p.id === g.pid);
return (
<Fragment key={g.pid}>
<div className="iw-cover-group-h">
{plat ? (
<>
<PlatformLogo p={plat} className="cg-logo" />
<span className="cg-name">{plat.name}</span>
</>
) : (
<span className="cg-name">未分类平台</span>
)}
<span className="cg-ct">{g.batches.length} 批</span>
</div>
{renderBatchCards(g.batches)}
</Fragment>
);
})}
</>
);
}
const featuredProducts = (() => {
const selected = visibleProducts.find((item) => item.id === productId);
const rest = visibleProducts.filter((item) => item.id !== productId);
@@ -1579,7 +1644,6 @@ export function ImageWorkbenchPage({
const rest = personAssets.filter((item) => item.id !== pickedIds[0]);
return (selected ? [selected, ...rest] : rest).slice(0, 3);
})();
const selectedPlatform = PLATFORM_OPTIONS.find((item) => item.id === pickedIds[0]);
const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS;
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
const openProductLibrary = () => {
@@ -1758,12 +1822,40 @@ export function ImageWorkbenchPage({
</div>
)}
</div>
<textarea
className="studio-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
/>
<div className="studio-prompt-wrap" ref={mentionMenuRef}>
<RichMentionEditor
ref={mentionEditorRef}
className="studio-prompt"
value={prompt}
refs={mentionRefs}
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
ariaLabel="图片创作提示词"
onChange={handleStudioPromptChange}
onAtTrigger={() => { void openImageMentions(); }}
/>
{mentionMenuOpen ? (
<div className="studio-mention-menu" role="dialog" aria-label="引用素材">
<div className="studio-mention-head">引用素材</div>
<div className="studio-mention-list">
{mentionLoading ? (
<span className="studio-mention-state">正在加载素材…</span>
) : mentionGroups.length === 0 ? (
<span className="studio-mention-state">暂无可引用的商品、模特或素材</span>
) : mentionGroups.map(([type, refs]) => (
<div className="studio-mention-group" key={type}>
<span>{mentionTypeLabels[type] || type}</span>
{refs.map((ref) => (
<button type="button" key={`${ref.type}:${ref.id}`} onClick={() => selectImageMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <i>{(mentionTypeLabels[ref.type] || ref.type).slice(0, 1)}</i>}
<b>{mentionName(ref)}</b>
</button>
))}
</div>
))}
</div>
</div>
) : null}
</div>
</div>
<div className="image-composer-footer">
<div className="image-composer-options">
@@ -1831,7 +1923,7 @@ export function ImageWorkbenchPage({
<div className="generator-layout">
<aside className="generator-panel">
<div className="asset-pair-grid">
<div className={`asset-pair-grid${mode === "cover" ? " single" : ""}`}>
<section className="control-section">
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
<div className="product-choice-list">
@@ -1866,7 +1958,7 @@ export function ImageWorkbenchPage({
</div>
</section>
{mode === "model" ? (
{mode === "model" && (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
<div className="model-choice-grid">
@@ -1895,33 +1987,24 @@ export function ImageWorkbenchPage({
</button>
</div>
</section>
) : (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择平台</strong></div>
<div className="yz-platform-grid">
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
className={`platform-choice ${pickedIds.includes(item.id) ? "active" : ""}`}
onClick={() => togglePick(item.id)}
>
<span className="choice-check" />
<PlatformLogo p={item} />
<span>{item.name}</span>
</button>
))}
</div>
</section>
)}
</div>
<section className={`control-section generator-settings${mode === "cover" ? " platform-generator-settings" : ""}`}>
<div className="control-heading"><span className="step-number">3</span><strong>生成设置</strong></div>
<section className={`control-section generator-settings${mode === "cover" ? " cover-generator-settings" : ""}`}>
<div className="control-heading">
<span className="step-number">{mode === "cover" ? 2 : 3}</span>
<strong>生成设置</strong>
{mode === "cover" && product && (
<button type="button" className="cover-info-reset" title="用商品库中的卖点、描述、人群与规格重新填充" onClick={() => setCoverInfo(coverInfoFromProduct(product))}>
<RefreshCw size={12} />
重新带入
</button>
)}
</div>
<div className="field-group">
<div className="field-label">
<span>生成数量</span>
<small>{mode === "cover" ? "覆盖不同图位" : "单次最多 4 张"}</small>
<small>{mode === "cover" ? "依次覆盖主图 / 卖点 / 细节 / 场景" : "单次最多 4 张"}</small>
</div>
<div className="choice-row">
{countOptions.map((value) => (
@@ -1983,35 +2066,107 @@ export function ImageWorkbenchPage({
)}
</div>
)}
<div className="field-group">
<label className="field-label">
<span>{mode === "cover" ? "画面要求" : "提示词"}</span>
<small>{mode === "cover" ? "平台规范已自动载入" : "可继续修改"}</small>
</label>
<textarea
className="image-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span>
{mode === "cover"
? (selectedPlatform ? `${selectedPlatform.name}规范` : "请选择平台")
: "自动匹配商品参考图"}
</span>
{mode === "cover" ? (
<>
{/* 商品信息(替代原「画面要求」):结构化提交 product_info,后端据此生成电商主图 */}
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-selling">
<span>核心卖点</span>
</label>
<textarea
id="cover-info-selling"
className="image-prompt cover-info-textarea"
value={coverInfo.sellingPoints}
onChange={(event) => updateCoverInfo("sellingPoints", event.target.value)}
placeholder={"例如:\n72 小时长效保湿\n无酒精,敏感肌可用"}
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-effect">
<span>商品功效</span>
</label>
<textarea
id="cover-info-effect"
className="image-prompt cover-info-textarea"
value={coverInfo.effect}
onChange={(event) => updateCoverInfo("effect", event.target.value)}
placeholder="例如:深层补水、舒缓干燥紧绷,让上妆更服帖"
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-audience">
<span>适用人群</span>
</label>
<input
id="cover-info-audience"
className="cover-info-input"
value={coverInfo.audience}
onChange={(event) => updateCoverInfo("audience", event.target.value)}
placeholder="例如:干皮 / 敏感肌、熬夜人群"
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-specs">
<span>规格参数</span>
</label>
<input
id="cover-info-specs"
className="cover-info-input"
value={coverInfo.specs}
onChange={(event) => updateCoverInfo("specs", event.target.value)}
placeholder="例如:200ml · 玻璃瓶装"
/>
</div>
<div className="field-group">
<label className="field-label" htmlFor="cover-info-notes">
<span>补充要求</span>
</label>
<input
id="cover-info-notes"
className="cover-info-input"
value={coverInfo.notes}
onChange={(event) => updateCoverInfo("notes", event.target.value)}
placeholder="例如:清透蓝白色调,浴室台面场景"
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span className={coverInfoReady ? "" : "cover-info-hint"}>
{!product
? "请先选择商品"
: coverInfoReady
? `引用商品图 ${product.images?.length || 0} 张 · 1:1 电商主图`
: "补充卖点后即可生成"}
</span>
</div>
</div>
</>
) : (
<div className="field-group">
<label className="field-label">
<span>提示词</span>
<small>可继续修改</small>
</label>
<textarea
className="image-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span>自动匹配商品参考图</span>
</div>
</div>
</div>
)}
</section>
<div className="generator-footer">
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
<WandSparkles />
<span>{mode === "cover" ? "生成平台套图" : "立即生成"}</span>
<span>{mode === "cover" ? "生成套图" : "立即生成"}</span>
</button>
<div className="cost-line">
<span>{mode === "cover" ? "生成成功后按实际数量结算" : "仅在生成成功后扣费"}</span>
<span>成功后扣费</span>
<strong>预计 {estimateFee(estimateCount)}</strong>
</div>
</div>
@@ -2021,21 +2176,11 @@ export function ImageWorkbenchPage({
<div className="result-topbar">
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
<div className="result-topbar-tools">
<div className="result-tags">
{mode === "cover" ? (
<>
<span>{selectedPlatform?.name || "未选平台"}</span>
<span>{count} 个图位</span>
<span>统一视觉</span>
</>
) : (
<>
<span>{ratio}</span>
<span>{count} 张</span>
<span>自动保存至成品库</span>
</>
)}
</div>
{!searchOpen && (
<div className="result-tags">
<span>{mode === "cover" ? `${count} 张套图` : `${ratio} · ${count} 张`}</span>
</div>
)}
{mode === "cover" && (
<>
<div className="tb-search-wrap">
@@ -2053,43 +2198,17 @@ export function ImageWorkbenchPage({
<Search size={14} />
</button>
</div>
<div className={`tb-menu-wrap chip-wrap iw-filter-wrap${filterOpen ? " open" : ""}`} data-filter="platform">
<button className={`search-btn${platformFilter.length ? " active" : ""}`} type="button" title="按平台筛选" onClick={() => setFilterOpen((v) => !v)}>
<SlidersHorizontal size={14} />
{platformFilter.length > 0 && <span className="iw-filter-count">{platformFilter.length}</span>}
</button>
<div className="chip-menu align-right iw-filter-menu">
<div className="iw-filter-h">
<span className="mono">按平台筛选</span>
<button type="button" className="iw-filter-clear" disabled={!platformFilter.length} onClick={() => setPlatformFilter([])}>清空</button>
</div>
<div className="iw-filter-grid">
{PLATFORM_OPTIONS.map((p) => (
<button
type="button"
key={p.id}
className={`iw-filter-opt${platformFilter.includes(p.id) ? " on" : ""}`}
onClick={() => setPlatformFilter((prev) => (prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id]))}
>
<PlatformLogo p={p} />
<span className="nm">{p.name}</span>
<Check className="ck" size={12} />
</button>
))}
</div>
</div>
</div>
</>
)}
</div>
</div>
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
{mode === "cover" && gridQuery.trim() && (
<div className="iw-filter-notice">
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
<span className="ifn-text">当前已开启搜索,部分内容可能隐藏</span>
<button type="button" className="ifn-clear" onClick={() => { setGridQuery(""); setSearchOpen(false); }}>
<X size={12} />
清空筛选
清空搜索
</button>
</div>
)}
@@ -2099,33 +2218,35 @@ export function ImageWorkbenchPage({
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
<p>
{mode === "cover"
? "选择平台后,系统会按对应图位和尺寸生成一组可继续编辑的电商素材。"
? "填写商品卖点与作用后,系统会结合商品图生成主图、卖点图、细节图与场景图。"
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
</p>
</div>
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
{renderBatchCards(productBatches)}
<div ref={resultsEndRef} aria-hidden="true" />
</>
)}
</div>
<div className="result-footer">
{mode === "cover" ? (
<>
<div className="result-stat"><span>主图</span><strong>白底 / 场景各 1 张</strong></div>
<div className="result-stat"><span>卖点证明</span><strong>2 个核心卖点</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
</>
) : (
<>
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
</>
)}
</div>
{hasResults && (
<div className="result-footer">
{mode === "cover" ? (
<>
<div className="result-stat"><span>图位</span><strong>主图 / 卖点 / 细节 / 场景</strong></div>
<div className="result-stat"><span>卖点依据</span><strong>{coverSellingCount ? `${coverSellingCount} 个核心卖点` : coverInfo.effect.trim() ? "商品功效" : "待填写"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
</>
) : (
<>
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
</>
)}
</div>
)}
</section>
</div>
@@ -2167,14 +2288,16 @@ export function ImageWorkbenchPage({
coverUrl={productCoverUrl}
onClose={() => setPlOpen(false)}
onConfirm={() => {
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特;
// 平台套图则用各自商品资料带入的商品信息 + 当前补充要求)
if (plDraft.length) {
setProductId(plDraft[0]);
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : true) && (mode === "cover" || prompt.trim())) {
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
const info = { ...coverInfoFromProduct(p), notes: coverInfo.notes };
void startBatch({ prompt: composeCoverPrompt(p?.title || "商品", info), ratio, count: candidateCount, productId: pid, productTitle: p?.title, productInfo: coverInfoPayload(info) });
} else {
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
}
+84 -23
View File
@@ -700,17 +700,27 @@ function strategyDocumentBody(payload: Record<string, unknown>): string {
}
function planDocumentBody(payload: Record<string, unknown>): string {
const goal = String(payload.goal || "").trim();
const duration = String(payload.duration || "").trim();
const concept = String(payload.concept || "").trim();
const usp = strategyField(payload, "usp", "主打卖点", "卖点");
const points = coercePlanPoints(payload.points);
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
const voice = coerceVoiceChars(payload.voice_chars);
const pointText = points.length ? points.map((point) => `- ${point}`).join("\n") : "—";
const timelineText = timeline.length
? timeline.map((item) => `- ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}${item.desc ? `:${item.desc}` : ""}`).join("\n")
? timeline.map((item) => [
`### ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}`,
`- 画面 / 剧情:${item.visual || item.desc || "—"}`,
`- 动作 / 对白:${item.action_dialogue || "—"}`,
`- 商品 / 卖点:${item.product || "—"}`,
`- 段落作用:${item.purpose || "—"}`,
].join("\n")).join("\n\n")
: "—";
const voiceText = voice.length === 2 ? `${voice[0]}–${voice[1]} 字,预计覆盖约 90% 时长` : "—";
return [
"# 视频最终方案",
"# 视频架构",
`## 创作概览\n- 视频目标:${goal || "—"}\n- 目标时长:${duration || "—"}\n- 创意概念:${concept || "—"}`,
`## 主打卖点 USP\n${usp || "—"}`,
`## 核心支撑\n${pointText}`,
`## 时间轴\n${timelineText}`,
@@ -999,7 +1009,16 @@ function StrategyCard({ payload }: { payload: Record<string, unknown> }) {
);
}
type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string };
type PlanTimelineItem = {
start: number;
end: number;
stage: string;
desc?: string;
visual?: string;
action_dialogue?: string;
product?: string;
purpose?: string;
};
function coercePlanPoints(raw: unknown): string[] {
if (Array.isArray(raw)) {
@@ -1039,6 +1058,9 @@ function coerceVoiceChars(raw: unknown): number[] {
}
function PlanCard({ payload }: { payload: Record<string, unknown> }) {
const goal = String(payload.goal || "").trim();
const duration = String(payload.duration || "").trim();
const concept = String(payload.concept || "").trim();
const usp = strategyField(payload, "usp", "主打卖点", "卖点");
const points = coercePlanPoints(payload.points);
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
@@ -1049,17 +1071,17 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
return (
<section className="omni-video-plan-card">
<header className="omni-video-plan-head">
<strong>视频最终方案</strong>
<strong>视频架构</strong>
<div className="omni-doc-head-actions">
<span>确认后进入出片参数核对</span>
<span>确认后撰写完整 Prompt</span>
<button
type="button"
className="omni-doc-download"
title="下载"
aria-label="下载视频最终方案"
aria-label="下载视频架构"
onClick={() => void downloadSessionResource({
kind: "document",
title: "视频最终方案.md",
title: "视频架构.md",
body: planDocumentBody(payload),
})}
>
@@ -1067,6 +1089,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
</button>
</div>
</header>
<section className="omni-plan-section omni-plan-overview">
<strong>创作概览</strong>
<div className="omni-plan-overview-grid">
<span><b>视频目标</b><em>{goal || "—"}</em></span>
<span><b>目标时长</b><em>{duration || "—"}</em></span>
<span><b>创意概念</b><em>{concept || "—"}</em></span>
</div>
</section>
<section className="omni-plan-section">
<strong>卖点做减法</strong>
<div className="omni-plan-points">
@@ -1087,11 +1117,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
<strong>Hook 只负责留人,正文负责说服</strong>
<div className="omni-plan-timeline">
{timeline.map((item, index) => (
<span key={`${item.stage}-${index}`}>
<span className="omni-plan-timeline-item" key={`${item.stage}-${index}`}>
<b>
{format(item.start)}–{format(item.end)} 秒 · {item.stage}
</b>
{item.desc || ""}
<em><i>画面 / 剧情</i>{item.visual || item.desc || "—"}</em>
<em><i>动作 / 对白</i>{item.action_dialogue || "—"}</em>
<em><i>商品 / 卖点</i>{item.product || "—"}</em>
<em><i>段落作用</i>{item.purpose || "—"}</em>
</span>
))}
</div>
@@ -1426,6 +1459,9 @@ function ElicitCard({
{isPet
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
: "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}
{Number(message.payload?.estimated_credits || 0) > 0
? ` 预计 ${Number(message.payload.estimated_credits)} 积分。`
: ""}
</span>
<button
type="button"
@@ -1834,6 +1870,20 @@ function ElicitCard({
<span>{submitted ? (saved._action === "cancel" ? "已取消" : "已回答") : "选一下就好"}</span>
</header>
<div className="omni-elicit-body">
{interaction === "product_brief_review" ? (
<div className="omni-product-brief-list">
{((message.payload.items as Array<Record<string, unknown>> | undefined) || []).map((item, index) => {
const status = String(item.status || "missing");
const statusLabel = status === "ready" ? "已获取" : status === "not_needed" ? "暂不需要" : "待补充";
return (
<div className={`omni-product-brief-item is-${status}`} key={`${String(item.label || "商品信息")}-${index}`}>
<span><b>{String(item.label || "商品信息")}</b>{item.value ? <em>{String(item.value)}</em> : null}</span>
<i>{statusLabel}</i>
</div>
);
})}
</div>
) : null}
{fields.map((field) => {
const value = answers[field.key];
return (
@@ -1976,14 +2026,18 @@ function ConfirmCard({
const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot);
const [initialDuration] = useState(snapshot.duration || "");
const [initialModel] = useState(snapshot.model || "");
const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged =
cardIsVideo
&& Boolean(draft.duration)
&& Boolean(initialDuration)
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
const modelChanged = cardIsVideo && Boolean(initialModel) && Boolean(draft.model) && draft.model !== initialModel;
const needsRebuild = durationChanged || modelChanged;
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
const generationPlan = (message.payload.generation_plan as Record<string, unknown> | undefined) || {};
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
const summary = paramLine(draft, cardIsVideo) || "当前参数";
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
@@ -2018,7 +2072,7 @@ function ConfirmCard({
: generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成"
: "确认前可以改参数。改时长会按新时长重写脚本。";
: "确认前可以改参数。改时长或模型会同步更新视频架构与 Prompt。";
const foot =
generationPhase === "done" ? "已完成出片"
: generationPhase === "failed" ? "出片失败"
@@ -2029,8 +2083,8 @@ function ConfirmCard({
: generationPhase === "failed" ? "生成失败"
: generationPhase === "running" || generationPhase === "submitted"
? "生成中"
: durationChanged
? "确认并重写脚本"
: needsRebuild
? "确认并同步架构"
: String(message.payload.label || "开始生成");
return (
@@ -2056,18 +2110,21 @@ function ConfirmCard({
/>
</div>
)}
{submitted ? null : durationChanged ? (
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
{submitted ? null : needsRebuild ? (
<p className="omni-confirm-hint">参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">
{durationSeconds} 秒成片一次生成,时长较长会多花一点时间,请耐心等待。
{draft.duration === snapshot.duration && generationPlan.note
? String(generationPlan.note)
: `${durationSeconds} 秒将分 ${Math.ceil(durationSeconds / 30)} 段生成后自动合并。`}
{credits > 0 ? ` 预计共 ${credits} 积分。` : ""}
</p>
) : null}
<div className="omni-confirm-foot">
<span>{foot}</span>
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
{buttonLabel}
{!submitted && !durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
{!submitted && !needsRebuild && credits > 0 ? <i>约 {credits} 积分</i> : null}
</button>
</div>
</section>
@@ -3303,10 +3360,10 @@ export function OmniSessionPage({
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
);
if (result.regenerate) {
notify("info", `时长已改为 ${nextParams.duration || ""},正在按新时长重写脚本`);
notify("info", "参数已更新,正在同步视频架构与 Prompt");
void send({
kind: "text",
text: `时长改成了${nextParams.duration},请按新参数重新写方案,旧方案作废`,
text: `生成参数已更新为:${paramLine(nextParams, true)}。请只更新受影响的视频架构与 Prompt,保留已经确认的商品信息、角色图和未受影响的段落。`,
});
return;
}
@@ -3965,15 +4022,19 @@ export function OmniSessionPage({
) : mentionResults.length === 0 ? (
<strong>这类还没有可引用的内容</strong>
) : (
mentionResults.map((ref) => (
<button type="button" key={ref.id} onClick={() => insertMention(ref)}>
mentionResults.map((ref) => {
const filename = ref.name.split(" · ")[0];
const typeLabel = typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label;
return (
<button type="button" key={ref.id} title={filename} onClick={() => insertMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
<span>
{ref.name.split(" · ")[0]}
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
<strong>{filename}</strong>
<small>{typeLabel}</small>
</span>
</button>
))
);
})
)}
</div>
</div>
+13
View File
@@ -780,6 +780,16 @@ export type ImageConversationTrash = ImageConversation & {
cover_preview_url: string;
};
// 平台套图 · 用户填写的商品信息(随生成请求提交 product_info,后端据此构建电商主图提示词)。
// 各字段均可选;selling_points 为多行文本(每行一个卖点)。
export type CoverProductInfo = {
selling_points?: string;
effect?: string;
audience?: string;
specs?: string;
notes?: string;
};
// 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源),
// 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。
export type WorkbenchTask = {
@@ -792,7 +802,10 @@ export type WorkbenchTask = {
product_id: string;
model_id: string;
model_entity_id: string;
/** 旧平台套图记录的平台 id;新记录不再选平台,为空串 */
platform_id: string;
/** 平台套图商品信息(新记录才有;重跑时沿用) */
product_info?: CoverProductInfo | null;
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean;
retry_of_task_id?: string;