fix: 电商测试清单一轮 bug 修复(视频项目角色/场景 + 图片工作台 + 视频收口)
视频项目(ZWQ): - 演员工作台保存后「我的演员」仍 0 个:保存时加入资产库(in_library=True) - 资产详情「应用当前角色」自动送审(不再要手动点灰盾) - 「我的演员」卡 + 待生成 seed 卡补删除入口(垃圾桶,两步确认) - 新增场景未生成就刷新会丢:sceneDrafts 按项目持久化 localStorage - 场景 AI 生成强制叠加「无真人」约束,避免出真人致视频被火山拒 - 进入项目默认落到当前阶段(不再恒显第 1 阶段) - 视频已出但进度卡 3/4:submit/poll/adopt 幂等补推 current_stage→VIDEO, 否则 settle_video_completion 因阶段守卫直接返回、永不收口 completed 图片工作台(PMC): - 生图走 action 全局单飞锁致并发重跑/多平台套图被拒成「失败/少出图」: action 加 concurrent 选项,生图不占单飞锁 - 切模块再切回状态丢/数量少:每批各自落盘 pendingIds(onSubmitted 回传), 切回时全部批次各自续轮询 附:含一处早前未提交的图片创作改动(选火山+传参考图强制切 gpt-image-2 + gen 气泡溢出 CSS)。 新增/通过测试:AttachOfficialModel、商品图删除、视频卡阶段收口、参考图强切模型。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1069,8 +1069,12 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
||||
elif kind == BaseAssetGroup.Kind.PERSON:
|
||||
gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板
|
||||
else:
|
||||
# 场景图:默认模板只是 {场景描述} 本身(零回归);可在 admin「提示词」页加全局场景风格
|
||||
# 场景图 = 空镜。不论 admin「提示词」页的 scene 模板写没写,都强制叠加「无真人」约束:
|
||||
# 否则模型常自作主张往场景里塞真人模特,下游用这张场景图生成视频时被火山以
|
||||
# 「输入图像可能包含真实人物」拒绝(ZWQ#13)。已含「真人」字样则不重复叠加。
|
||||
gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip())
|
||||
if "真人" not in gen_prompt:
|
||||
gen_prompt = f"{gen_prompt},空镜场景、只有环境与陈设,画面中不要出现任何真人 / 人物 / 模特 / 人脸 / 人手,干净构图"
|
||||
payload = {
|
||||
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
|
||||
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
|
||||
@@ -2069,6 +2073,13 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
||||
count = max(1, min(int(count or 1), 12))
|
||||
ref_ids = [str(r) for r in (reference_image_ids or []) if r]
|
||||
# 带参考图的图片创作必须走 image_edit(gpt-image)才真的还原上传素材——火山 Seedream 的图生图只松散
|
||||
# 借色调、不锁主体(与三视图/模特上身图同理,那两条流程也强制 image_edit)。当前模型不支持 image_edit
|
||||
# 就自动切到 gpt-image-2,否则用户选了火山却传了参考图 → 生成完全不参考素材(用户实测的根因)。
|
||||
if ref_ids and not hasattr(build_provider(model_config), "image_edit"):
|
||||
alt = resolve_image_model("gpt-image")
|
||||
if alt is not None:
|
||||
model_config = alt
|
||||
# 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。
|
||||
batch_id = str(uuid.uuid4())
|
||||
tasks: list[AITask] = []
|
||||
|
||||
@@ -270,6 +270,26 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
self.assertIn("严格保留", used_prompt)
|
||||
prov.image_generation.assert_not_called()
|
||||
|
||||
def test_refs_force_image_edit_model_when_volcano_selected(self):
|
||||
"""用户选了火山 Seedream(只有图生图、不会真锁主体)却传了参考图时,必须自动切到支持
|
||||
image_edit 的 gpt-image-2——否则参考图形同虚设。回归保护「选火山+传参考图=完全不参考」。"""
|
||||
from apps.ai.models import ModelProvider
|
||||
|
||||
vp = ModelProvider.objects.create(name="volcengine", display_name="火山")
|
||||
ModelConfig.objects.create(provider=vp, name="seedream-4", display_name="Seedream", capability=ModelConfig.Capability.IMAGE)
|
||||
self._patch_provider()
|
||||
ref = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="背心", asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD, category=Asset.Category.UPLOAD,
|
||||
)
|
||||
AssetFile.objects.create(asset=ref, object_key="r.png", bucket="b", content_type="image/png", preview_url="http://x/ref.png", is_primary=True)
|
||||
tasks = enqueue_standalone_images(
|
||||
team=self.team, user=self.user, prompt="不同场景", mode="image", count=1,
|
||||
image_model="volcano", reference_image_ids=[str(ref.id)],
|
||||
)
|
||||
# 任务最终落在支持 image_edit 的模型(gpt-image),而不是用户选的火山 Seedream
|
||||
self.assertIn("gpt-image", tasks[0].model_config.name)
|
||||
|
||||
|
||||
class ImageConversationTests(TestCase):
|
||||
"""图片创作「对话」实体:CRUD + 团队隔离 + 软删 + 生图自动归属对话 + 任务回填。"""
|
||||
|
||||
@@ -624,6 +624,30 @@ class ProjectApiTests(TestCase):
|
||||
self.assertEqual(clip.trim_start_ms, 0)
|
||||
self.assertIsNone(clip.trim_end_ms)
|
||||
|
||||
def test_video_done_settles_even_if_stage_stuck_at_storyboard(self):
|
||||
"""ZWQ#15:项目 current_stage 还停在 storyboard,但视频段都出片并采用 → 必须收口成 completed,
|
||||
而不是永远卡在「3/4 故事板」。修复前 settle_video_completion 因 current_stage != VIDEO 直接返回 → 卡死。"""
|
||||
project = Project.objects.create(
|
||||
team=self.team, created_by=self.user, product=self.product, name="P",
|
||||
current_stage=ProjectStage.Stage.STORYBOARD, status=Project.Status.STORYBOARDING,
|
||||
)
|
||||
seg = VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="clip",
|
||||
asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=asset)
|
||||
|
||||
res = self.client.post(
|
||||
f"/api/projects/{project.id}/adopt-video-version/",
|
||||
{"video_segment_id": str(seg.id), "version_id": str(ver.id)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
project.refresh_from_db()
|
||||
self.assertEqual(project.current_stage, ProjectStage.Stage.VIDEO)
|
||||
self.assertEqual(project.status, Project.Status.COMPLETED)
|
||||
|
||||
def _make_audio_model(self):
|
||||
return ModelConfig.objects.create(
|
||||
provider=self.provider, name="doubao-voice-bigtts", display_name="豆包语音合成",
|
||||
|
||||
@@ -160,6 +160,16 @@ def promote_base_asset_stage_if_ready(project: Project) -> bool:
|
||||
VIDEO_IS_FINAL_STAGE = True
|
||||
|
||||
|
||||
def _ensure_video_stage(project: Project) -> None:
|
||||
"""幂等地把项目推进到「视频」阶段(生成视频片段时调用)。current_stage 没到 VIDEO 时,
|
||||
settle_video_completion 会直接返回 → 全段出片也不收口、卡在故事板格(ZWQ#15)。"""
|
||||
if project.current_stage != ProjectStage.Stage.VIDEO:
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
if project.status != Project.Status.COMPLETED:
|
||||
project.status = Project.Status.VIDEOING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
|
||||
|
||||
def settle_video_completion(project: Project) -> bool:
|
||||
"""视频阶段收口(幂等):
|
||||
· 全部片段已出片(SUCCEEDED + 有采用版)→ 项目 COMPLETED、VIDEO 阶段标成功;
|
||||
@@ -745,7 +755,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
timeline.clips.filter(asset_id__in=segment_asset_ids).exclude(asset_id=version.asset_id).update(
|
||||
asset_id=version.asset_id, trim_start_ms=0, trim_end_ms=None
|
||||
)
|
||||
# 采用历史版后该段为已出片 → 若全部段就绪,收口项目为 completed
|
||||
# 采用历史版后该段为已出片 → 先确保在视频阶段,再收口项目为 completed(否则卡 3/4,ZWQ#15)
|
||||
_ensure_video_stage(project)
|
||||
settle_video_completion(project)
|
||||
return Response(ProjectSerializer(project).data)
|
||||
|
||||
@@ -856,6 +867,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.error("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
|
||||
# 生成视频即代表已进入视频阶段:某些路径(跳过故事板/异常)可能没把 current_stage 推到 VIDEO,
|
||||
# 而 settle_video_completion 只在 current_stage==VIDEO 时收口 → 不补推的话,全段出片后项目会永远停在
|
||||
# 「3/4 故事板」、永不 completed(ZWQ#15)。这里幂等补推。
|
||||
_ensure_video_stage(project)
|
||||
# 重跑某段 → 该段回到在制;若项目此前已 completed,退回 videoing
|
||||
settle_video_completion(project)
|
||||
return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED)
|
||||
@@ -867,7 +882,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
version = poll_video_segment(video_segment=segment, user=request.user)
|
||||
if version is None:
|
||||
return Response({"status": segment.status}, status=status.HTTP_202_ACCEPTED)
|
||||
# 这一段刚出片 → 若是最后一段,收口整个项目为 completed(视频是末阶段)
|
||||
# 这一段刚出片 → 先确保已在视频阶段(否则 settle 不收口,卡 3/4),再收口整个项目为 completed
|
||||
_ensure_video_stage(project)
|
||||
settle_video_completion(project)
|
||||
return Response(VideoSegmentVersionSerializer(version).data)
|
||||
|
||||
|
||||
@@ -436,12 +436,16 @@ export function App() {
|
||||
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
|
||||
const actionInFlightRef = useRef(false);
|
||||
|
||||
async function action<T>(work: () => Promise<T>, successText: string, opts?: { liteRefresh?: boolean }): Promise<T | null> {
|
||||
if (actionInFlightRef.current) {
|
||||
setNotice({ type: "error", text: "操作进行中,请稍候…" });
|
||||
return null;
|
||||
async function action<T>(work: () => Promise<T>, successText: string, opts?: { liteRefresh?: boolean; concurrent?: boolean }): Promise<T | null> {
|
||||
// concurrent=true:可并行的长任务(图片生成/重跑——工作台明确支持多批并行),不参与全局单飞锁,
|
||||
// 否则「正在生成时点另一张重跑」会被单飞锁拒成 null → 该批被标「失败」(PMC#8)。
|
||||
if (!opts?.concurrent) {
|
||||
if (actionInFlightRef.current) {
|
||||
setNotice({ type: "error", text: "操作进行中,请稍候…" });
|
||||
return null;
|
||||
}
|
||||
actionInFlightRef.current = true;
|
||||
}
|
||||
actionInFlightRef.current = true;
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
@@ -459,7 +463,7 @@ export function App() {
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
actionInFlightRef.current = false;
|
||||
if (!opts?.concurrent) actionInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,14 +505,17 @@ 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; conversation_id?: string; reference_image_ids?: string[] }) {
|
||||
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; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) {
|
||||
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
||||
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
||||
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
||||
const { onSubmitted, ...apiPayload } = payload; // onSubmitted 是本地回调,不发后端
|
||||
return action(async () => {
|
||||
const { tasks, conversation_id } = await api.submitGenerateImage(payload);
|
||||
const { tasks, conversation_id } = await api.submitGenerateImage(apiPayload);
|
||||
const ids = tasks.map((t) => t.id);
|
||||
if (ids.length === 0) throw new Error("未能提交生成任务");
|
||||
// 把刚提交的任务 id 回传给工作台,让它按「批」各自落盘 pending → 切走再回来每一批都能恢复并续轮询(PMC#5/#10)
|
||||
onSubmitted?.(ids);
|
||||
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
|
||||
// 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名)
|
||||
const batchProduct = products.find((p) => p.id === payload.product_id);
|
||||
@@ -516,7 +523,8 @@ export function App() {
|
||||
const res = await pollImageTasks(payload.mode, ids);
|
||||
// 回传后端归属/新建的对话 id,供工作室把它登记进左栏会话列表并设为 active
|
||||
return { ...res, conversation_id };
|
||||
}, "图片已生成");
|
||||
// concurrent:生图支持多批并行,不占全局单飞锁(否则并发重跑被拒成 null→标失败,PMC#8)
|
||||
}, "图片已生成", { concurrent: true });
|
||||
}
|
||||
|
||||
// 轮询一批已提交的生图任务直到全部出图/超时;每出一张就回写本地,刷新后可恢复。
|
||||
|
||||
@@ -697,16 +697,18 @@
|
||||
position: relative;
|
||||
aspect-ratio: var(--ratio, 1 / 1);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
/* 不在此裁切:否则「更多」气泡(.gen-bubble)作为后代会被 overflow:hidden 切掉。
|
||||
改为给图片/占位各自圆角,卡片保持圆角的同时让气泡能溢出展示。 */
|
||||
cursor: pointer;
|
||||
}
|
||||
.image-workbench .gen-image .placeholder { position: absolute; inset: 0; }
|
||||
.image-workbench .gen-image .placeholder { position: absolute; inset: 0; border-radius: var(--r-md); overflow: hidden; }
|
||||
/* 生成结果真图 · 填满 .gen-image(比例由容器 aspect-ratio 控制) */
|
||||
.image-workbench .gen-image-img {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
/* 右上浮层按钮组(§4.18 .gen-image-actions) */
|
||||
|
||||
@@ -134,21 +134,39 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
if (r) setTriReady(true);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
// 保存人物:写名字(若有改名能力)→ 刷新 → 关工作台 → 切「我的演员」tab
|
||||
// 保存人物:写名字(若有改名能力)→ 加入资产库 → 刷新 → 关工作台 → 切「我的演员」tab
|
||||
async function saveActor() {
|
||||
if (!picked) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const name = actorName.trim();
|
||||
if (name && name !== picked.name && onRename) await onRename(picked.id, name);
|
||||
// 关键:AI 生成/上传得到的立绘默认 in_library=False(工作台暂存),不入「我的演员」列表(只列已入库 person)。
|
||||
// 「保存人物」必须把它加入资产库,否则保存后「我的演员」始终 0 个演员(ZWQ#6)。
|
||||
await api.setAssetLibrary(picked.id, true).catch(() => undefined);
|
||||
onRefresh();
|
||||
void reload(); // 新保存的人物即时进「我的演员」列表(自取,不依赖父级全局 assets)
|
||||
await reload(); // 新保存的人物即时进「我的演员」列表(自取,不依赖父级全局 assets)
|
||||
setStudio(false);
|
||||
setCandidates([]); setPicked(null); setActorName(""); setTriReady(false);
|
||||
setTab("mine");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// 删除「我的演员」(软删 person 资产)· 两步确认,3s 自动撤销(ZWQ#8)
|
||||
const [delArmId, setDelArmId] = useState<string | null>(null);
|
||||
const [delBusyId, setDelBusyId] = useState<string | null>(null);
|
||||
async function deleteActor(id: string) {
|
||||
setDelBusyId(id);
|
||||
try {
|
||||
await api.deleteAsset(id);
|
||||
onRefresh();
|
||||
await reload();
|
||||
} catch { /* 删除失败保留卡片,可重试 */ } finally {
|
||||
setDelBusyId(null);
|
||||
setDelArmId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div className="actorlib-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
@@ -262,8 +280,21 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
<div className="actorlib-grid">
|
||||
{pageList.map((a) => {
|
||||
const url = previewOf(a);
|
||||
// 「我的演员」(非模特库预设)可删:右上角垃圾桶,两步确认(ZWQ#8)
|
||||
const canDelete = tab === "mine" && !isPreset(a);
|
||||
return (
|
||||
<div className="actor-card" key={a.id}>
|
||||
<div className="actor-card" key={a.id} style={{ position: "relative" }}>
|
||||
{canDelete && (delArmId === a.id ? (
|
||||
<span className="asset-card-del-confirm" data-stop style={{ position: "absolute", top: 6, right: 6, zIndex: 3, display: "flex", gap: 4 }}>
|
||||
<button type="button" className="del-yes" disabled={delBusyId === a.id} onClick={(e) => { e.stopPropagation(); void deleteActor(a.id); }}>{delBusyId === a.id ? "删除中…" : "确认删除"}</button>
|
||||
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setDelArmId(null); }}>取消</button>
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="asset-card-del" title="删除该演员" style={{ position: "absolute", top: 6, right: 6, zIndex: 3 }}
|
||||
onClick={(e) => { e.stopPropagation(); setDelArmId(a.id); window.setTimeout(() => setDelArmId((v) => (v === a.id ? null : v)), 3000); }}>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" 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 className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
|
||||
role="button" tabIndex={0} title={mode === "replace" ? "选用此演员" : "查看大图"}
|
||||
onClick={() => { if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } }}
|
||||
|
||||
@@ -510,9 +510,10 @@ const MODE_META: Record<
|
||||
cover: {
|
||||
title: "平台套图",
|
||||
tag: "[ PLATFORM · KIT ]",
|
||||
desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。",
|
||||
desc: "选择平台模板,一键生成电商平台头图 / 主图。",
|
||||
ratio: "4:5",
|
||||
promptTemplate: (title) => `${title},电商平台套图,统一视觉,主图 + 详情排版`
|
||||
// YYX#4:只出平台头图,去掉「详情排版」
|
||||
promptTemplate: (title) => `${title},电商平台头图,统一视觉,主图特写`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -582,6 +583,8 @@ type GenBatch = {
|
||||
platformIds?: string[];
|
||||
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
|
||||
refs?: { name: string; url: string }[];
|
||||
/** 该批次已提交的生图任务 id:落盘后切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
|
||||
pendingIds?: string[];
|
||||
};
|
||||
|
||||
export function ImageWorkbenchPage({
|
||||
@@ -600,7 +603,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; conversation_id?: string; reference_image_ids?: string[] }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
|
||||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
|
||||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||||
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
||||
initialProductId?: string;
|
||||
@@ -732,14 +735,10 @@ export function ImageWorkbenchPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId, mode]);
|
||||
|
||||
// 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组)
|
||||
// 模特 / 平台都单选(YYX#1:平台只能单选):再点同一张取消,点别张替换
|
||||
function togglePick(id: string, name?: string) {
|
||||
if (mode === "cover") {
|
||||
setPickedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
return;
|
||||
}
|
||||
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
|
||||
setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
|
||||
if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
|
||||
}
|
||||
// 单图就地反馈(§4.18):显示 1.5s 后自动撤掉
|
||||
function flashFeedback(key: string) {
|
||||
@@ -770,8 +769,12 @@ export function ImageWorkbenchPage({
|
||||
const batchKey = `airshelf:imgwb:batches:${mode}`;
|
||||
function persistBatches(list: GenBatch[]) {
|
||||
try {
|
||||
// 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死)
|
||||
const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status }));
|
||||
// 已出图/失败的批次照常落盘;仍在跑(generating)但已拿到 pendingIds 的批次也落盘(保留 generating + pendingIds),
|
||||
// 这样切走再回来每一批都能按各自的 pendingIds 续轮询,不再「数量少 / 只恢复一批」(PMC#5/#10)。
|
||||
// 还没拿到 pendingIds 的占位批次(刚点、尚未提交成功)不落盘,避免恢复出一个永远空转的壳。
|
||||
const slim = list
|
||||
.filter((b) => b.status !== "generating" || (b.pendingIds && b.pendingIds.length))
|
||||
.map((b) => ({ ...b, refs: undefined })); // refs 含本地 blob url,不落盘
|
||||
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
|
||||
} catch {
|
||||
/* localStorage 不可用时静默降级 */
|
||||
@@ -955,7 +958,9 @@ export function ImageWorkbenchPage({
|
||||
referenceImageIds = ids.filter((x): x is string => !!x);
|
||||
}
|
||||
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
|
||||
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, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds });
|
||||
// onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10)
|
||||
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, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
|
||||
onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) });
|
||||
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
|
||||
const convId = result?.conversation_id;
|
||||
if (convId && convId !== activeConvRef.current) {
|
||||
@@ -1002,7 +1007,7 @@ export function ImageWorkbenchPage({
|
||||
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
|
||||
for (const pid of pickedIds) {
|
||||
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
|
||||
void startBatch({ ...base, prompt: platName ? `${base.prompt},${platName}平台套图版式` : base.prompt, platformIds: [pid] });
|
||||
void startBatch({ ...base, prompt: platName ? `${base.prompt},${platName}平台头图版式` : base.prompt, platformIds: [pid] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1116,55 +1121,39 @@ export function ImageWorkbenchPage({
|
||||
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// image 模式的历史现由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
|
||||
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
|
||||
if (mode === "image") return;
|
||||
// 1) 多批次结果回显
|
||||
// 模特/平台套图:从多批次残留恢复「全部」批次 —— 已出图/失败的直接回显;仍在跑(带 pendingIds)的
|
||||
// 恢复成 generating 并各自按本批 pendingIds 续轮询。这样切走再回来,多批/多平台都在,不再「数量少 / 分类丢」(PMC#5/#10)。
|
||||
try {
|
||||
const raw = localStorage.getItem(batchKey);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number };
|
||||
if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) {
|
||||
const restored = (saved.batches || []).filter((b) => b.results && b.results.length);
|
||||
if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const })));
|
||||
const all = saved.batches || [];
|
||||
if (all.length) {
|
||||
setBatches(all);
|
||||
if (onResume) {
|
||||
for (const b of all) {
|
||||
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
|
||||
onResume(mode, b.pendingIds).then((res) => {
|
||||
if (cancelled) return;
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((x) =>
|
||||
x.id === b.id ? { ...x, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || x.results } : x
|
||||
);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 残留解析失败则忽略 */
|
||||
}
|
||||
// 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报)
|
||||
try {
|
||||
const raw = localStorage.getItem(`airshelf:imgwb:${mode}`);
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string };
|
||||
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return;
|
||||
const pending = saved.pending || [];
|
||||
if (pending.length && onResume) {
|
||||
const batchId = `b-resume-${Date.now()}`;
|
||||
setBatches((prev) => {
|
||||
// 已由多批次残留恢复过结果就不重复加占位
|
||||
if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
// 商品归属用生成时落盘的 saved.productId/Title,绝不用「当前选中商品」——切走再回来当前选中可能已变,
|
||||
// 那正是导航头显示错名(如把耳机包批次显示成蓝牙耳机)的根因。
|
||||
{ id: batchId, prompt: meta.promptTemplate(saved.productTitle || product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: saved.productId, productTitle: saved.productTitle }
|
||||
];
|
||||
});
|
||||
onResume(mode, pending)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b
|
||||
);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 残留解析失败则忽略 */
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -1172,7 +1161,18 @@ export function ImageWorkbenchPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
const hasResults = batches.length > 0;
|
||||
// YYX#3:工作台数据按商品隔离 —— 模特/平台模式右侧结果只展示当前选中商品的批次
|
||||
// (旧批次无 productId 则全局可见,兜底);image 模式按对话维度,沿用全部 batches。
|
||||
// YYX#12:右上搜索框(gridQuery)在平台套图下用来筛「工作台图片」(按提示词匹配),不再清空左侧平台网格。
|
||||
const productBatches = useMemo(() => {
|
||||
let list = mode === "image" ? batches : batches.filter((b) => !b.productId || b.productId === productId);
|
||||
if (mode === "cover" && gridQuery.trim()) {
|
||||
const q = gridQuery.trim().toLowerCase();
|
||||
list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q));
|
||||
}
|
||||
return list;
|
||||
}, [batches, productId, mode, gridQuery]);
|
||||
const hasResults = productBatches.length > 0;
|
||||
|
||||
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
|
||||
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除)
|
||||
@@ -1187,14 +1187,19 @@ export function ImageWorkbenchPage({
|
||||
className="gen-images"
|
||||
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
|
||||
>
|
||||
{(batch.results.length
|
||||
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id, inLib: !!asset.in_library }))
|
||||
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "", inLib: false }))
|
||||
).map(({ key, index, url, assetId, inLib }) => {
|
||||
{/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败,
|
||||
给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */}
|
||||
{Array.from({ length: Math.max(batch.count, batch.results.length) }).map((_, index) => {
|
||||
const asset = batch.results[index];
|
||||
const url = asset?.files?.[0]?.preview_url;
|
||||
const assetId = asset?.id || "";
|
||||
const inLib = !!asset?.in_library;
|
||||
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
|
||||
const key = assetId || `ph-${index}`;
|
||||
const cellKey = `${batch.id}:${assetId || key}`;
|
||||
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
|
||||
return (
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
|
||||
{url ? (
|
||||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||||
) : (
|
||||
@@ -1203,6 +1208,16 @@ export function ImageWorkbenchPage({
|
||||
<span className="gen-loading" aria-label="生成中">
|
||||
<span className="spinner" aria-hidden />
|
||||
</span>
|
||||
) : failed ? (
|
||||
/* YYX#5:这张失败了 —— 占位提示 + 单张重跑 */
|
||||
<span className="gen-failed">
|
||||
<X size={18} />
|
||||
<span className="gf-text">这张生成失败</span>
|
||||
<button type="button" className="gf-retry" onClick={() => regenSingleImage(batch)}>
|
||||
<RefreshCw size={12} />
|
||||
重跑这张
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||||
)}
|
||||
@@ -1679,21 +1694,7 @@ export function ImageWorkbenchPage({
|
||||
{product?.title || "未选择 · 请在左侧商品空间选一个"}
|
||||
</span>
|
||||
</div>
|
||||
{/* 生图模型选择(默认火山,可切 gpt-image-2;选择写入 localStorage) */}
|
||||
<div className={`tb-menu-wrap chip-wrap${genModelOpen ? " open" : ""}`} data-filter="genmodel">
|
||||
<button className="tb-chip" type="button" title="生图模型" onClick={() => setGenModelOpen((v) => !v)}>
|
||||
<Sparkles size={12} />
|
||||
<span className="lbl">{GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}</span>
|
||||
<svg 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">
|
||||
{GEN_MODEL_OPTIONS.map((o) => (
|
||||
<div className={`mi${genModel === o.value ? " selected" : ""}`} key={o.value} role="button" tabIndex={0} onClick={() => { setGenModel(o.value); setGenModelOpen(false); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{o.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* YYX#10:生图模型选择已移到下方提示词输入框内(胶囊靠左置底),此处不再放 */}
|
||||
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
|
||||
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
|
||||
<LayoutGrid size={13} />
|
||||
@@ -1702,7 +1703,7 @@ export function ImageWorkbenchPage({
|
||||
<span className="spacer" />
|
||||
<div className="tb-search-wrap">
|
||||
{searchOpen && (
|
||||
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索平台"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
|
||||
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索图片提示词"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
|
||||
)}
|
||||
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title={mode === "model" ? "搜索批次/模特" : "搜索"} onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
|
||||
<Search size={14} />
|
||||
@@ -1806,7 +1807,8 @@ export function ImageWorkbenchPage({
|
||||
<span className="title">选择平台</span>
|
||||
</div>
|
||||
<div className="platform-grid">
|
||||
{PLATFORM_OPTIONS.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())).map((item) => (
|
||||
{/* YYX#12:平台网格恒显全部,右上搜索只筛右侧结果图,不再把平台过滤没 */}
|
||||
{PLATFORM_OPTIONS.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
@@ -1932,8 +1934,8 @@ export function ImageWorkbenchPage({
|
||||
) : (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头 */}
|
||||
{mode === "cover" ? renderCoverGrouped(batches) : renderBatchCards(batches)}
|
||||
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头。按商品隔离 → productBatches */}
|
||||
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -583,6 +583,20 @@ export function PipelinePage(props: {
|
||||
if (rm) undoTagEdit(rm.id); else queueRemoveTag(kind, value);
|
||||
}
|
||||
const tagEditVerb = (e: TagEdit) => `${e.op === "add" ? "添加" : "删除"}${e.kind === "cast" ? "人物" : "场景"}:${e.value}`;
|
||||
// 直接删除一个「待生成」seed 标签(还没出参考图的角色/场景):立即从 cast/scenes 移除并落库,
|
||||
// 不走「整体重写」的待删队列——给 seed 卡一个能用的删除入口(ZWQ#9)。assetKind: person↔cast / scene↔scene。
|
||||
function deleteSeedTag(assetKind: "person" | "scene", tag: string) {
|
||||
if (assetKind === "person") {
|
||||
const next = castTags.filter((t) => t !== tag);
|
||||
setCastTags(next);
|
||||
void onSaveProjectMeta?.({ cast: next });
|
||||
} else {
|
||||
const next = sceneTags.filter((t) => t !== tag);
|
||||
setSceneTags(next);
|
||||
void onSaveProjectMeta?.({ scenes: next });
|
||||
}
|
||||
}
|
||||
const [seedDelArmed, setSeedDelArmed] = useState<string | null>(null);
|
||||
// 行34 · 「添加分镜」插入的本地空白可编辑卡(尚未落库;失焦有内容才真正 onAddShot)
|
||||
type DraftShot = { id: string; afterId: string | null; narration: string; visual: string };
|
||||
const [draftShots, setDraftShots] = useState<DraftShot[]>([]);
|
||||
@@ -598,7 +612,14 @@ export function PipelinePage(props: {
|
||||
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
|
||||
// 新增场景:先落「占位草稿卡」(标题 + 可编辑提示词),用户编辑完点「AI 生成」才真正出图;
|
||||
// 出图成功后从草稿列表移除(生成结果会作为实体卡出现在下方)。不直接生成,避免一点就出一张默认图。
|
||||
const [sceneDrafts, setSceneDrafts] = useState<Array<{ id: string; title: string; prompt: string }>>([]);
|
||||
// 持久化到 localStorage(按项目隔离):新增场景没点 AI 生成就刷新页面也不会丢(ZWQ#10)。
|
||||
const sceneDraftsKey = `airshelf:scenedrafts:${project.id}`;
|
||||
const [sceneDrafts, setSceneDrafts] = useState<Array<{ id: string; title: string; prompt: string }>>(() => {
|
||||
try { const raw = localStorage.getItem(sceneDraftsKey); return raw ? (JSON.parse(raw) as Array<{ id: string; title: string; prompt: string }>) : []; } catch { return []; }
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(sceneDraftsKey, JSON.stringify(sceneDrafts)); } catch { /* localStorage 不可用时静默降级 */ }
|
||||
}, [sceneDrafts, sceneDraftsKey]);
|
||||
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
|
||||
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||
@@ -1067,7 +1088,9 @@ export function PipelinePage(props: {
|
||||
// V1 雪藏拼接导出:已完成项目落到第 4 阶段(视频),不落到被藏的第 5(V2 恢复改回 5)
|
||||
const projectStage = project.status === "completed" ? 4 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
|
||||
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1);
|
||||
// 进入项目默认落到「项目当前进行到的阶段」(视频在生成/已生成 → 直接到第4阶段),而不是恒显第1阶段(ZWQ#16)。
|
||||
// 仍尊重地址栏 #stage-N(分享某阶段链接 / 浏览器前进后退)。
|
||||
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : projectStage);
|
||||
const [navigated, setNavigated] = useState(Boolean(initHash));
|
||||
// 火山人像审核状态(真人资产绿/红盾):本地覆盖 map(轮询刷新),回退 asset.review_status
|
||||
const [reviews, setReviews] = useState<Record<string, string>>({});
|
||||
@@ -3019,6 +3042,17 @@ export function PipelinePage(props: {
|
||||
const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`) || pendingHas(kind, tag);
|
||||
return (
|
||||
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
|
||||
{/* 待生成 seed 卡也可删除(还没出参考图也有垃圾桶):两步确认,直接从脚本标签里移除(ZWQ#9) */}
|
||||
{seedDelArmed === seedKey ? (
|
||||
<span className="asset-card-del-confirm" data-stop>
|
||||
<button type="button" className="del-yes" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(null); deleteSeedTag(kind, tag); }}>确认删除</button>
|
||||
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(null); }}>取消</button>
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="asset-card-del" data-stop title="删除该角色/场景" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(seedKey); window.setTimeout(() => setSeedDelArmed((v) => (v === seedKey ? null : v)), 3000); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" 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 className="placeholder thumb-2" role="button" tabIndex={0} style={{ cursor: "pointer" }} title="点击进详情(先生成立绘,再生成三视图)"
|
||||
onClick={() => openSeedDetail(kind, tag, promptValue)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }}>
|
||||
{busy
|
||||
@@ -4036,8 +4070,16 @@ export function PipelinePage(props: {
|
||||
{portraitUrl && <button className="ad-stat-btn" type="button" onClick={() => void downloadAssetImage(viewPortraitAsset, `${entity.name}-立绘`)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>下载立绘</button>}
|
||||
</div>
|
||||
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
|
||||
if (viewPortraitAsset) await onAdoptBaseAsset(grp.id, viewPortraitAsset);
|
||||
if (triGroup && viewTriAsset) await onAdoptBaseAsset(triGroup.id, viewTriAsset);
|
||||
// 应用当前角色 = 采用 + 自动送审:不再要用户应用完还得手动点灰盾送审(ZWQ#7)。
|
||||
// 立绘(person)/三视图(tri_view)都属送审类;送审异步(processing→绿盾),失败由 submitAssetReview 自行提示。
|
||||
if (viewPortraitAsset) {
|
||||
await onAdoptBaseAsset(grp.id, viewPortraitAsset);
|
||||
if (assetReview(viewPortraitAsset) !== "active") void submitAssetReview(viewPortraitAsset);
|
||||
}
|
||||
if (triGroup && viewTriAsset) {
|
||||
await onAdoptBaseAsset(triGroup.id, viewTriAsset);
|
||||
if (assetReview(viewTriAsset) !== "active") void submitAssetReview(viewTriAsset);
|
||||
}
|
||||
setAdDetail(null);
|
||||
}}>应用当前角色</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user