feat(pipeline): 基础资产页 A三视图手动 + B进故事板拦截 + C采用/未采用 + D删除

- A 三视图改手动:删"打开角色详情自动出三视图"逻辑、角色生成只出立绘(不再链式自动三视图);
  详情弹窗保留「AI 生成三视图」按钮作手动入口(提示文案去掉"自动")
- B 进下一步拦截:「确认资产,进入故事板」按钮挂 guardGen —— 被引用角色/场景缺"已采用"参考图就弹窗拦
- C 采用/未采用:角色/场景卡加 toggle 徽章(已采用绿/未采用灰);默认按"实体名是否在脚本里"派生,
  用户可点击覆盖并持久化(metadata.adopt);后端 set-base-asset-adopt 端点;下游 _storyboard_reference_images
  只取已采用组(商品组永远保留、顺带排除未采用/三视图组的兜底误抓)
- D 删除:角色/场景卡 hover 显删除按钮(二次确认),商品主卡不给删;后端 delete-base-asset(连带三视图组)
- 顺手:提取闸门按钮去掉"不自动花钱"文案

验收:projects/assets/ai 全套(基线既有 7 失败零新增,新增 set-adopt/delete 3 测过);tsc+build 绿;
无头核对(采用 toggle/hover 删除/待审核盾齐活、0 console error)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-22 19:23:02 +08:00
parent fda0385d2f
commit 612ab6864d
9 changed files with 184 additions and 22 deletions
+16
View File
@@ -1024,6 +1024,22 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
"scene": BaseAssetGroup.Kind.SCENE,
}
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
# 下游只取「已采用」的角色/场景组(C/D):metadata.adopt 显性优先,否则按是否在脚本里推导;
# 商品组永远保留(不受采用态影响)。顺带把不在脚本里的(含三视图组 label「·三视图」)排除,
# 避免兜底误抓未采用/三视图组。
_entity_names = {(e.get("name") or "").strip() for e in entities.values() if (e.get("name") or "").strip()}
def _group_adopted(g) -> bool:
if g.kind == BaseAssetGroup.Kind.PRODUCT:
return True
a = (g.metadata or {}).get("adopt")
if a == "adopted":
return True
if a == "unadopted":
return False
return (g.metadata or {}).get("label", "").strip() in _entity_names
groups = [g for g in groups if _group_adopted(g)]
out: list[dict] = []
used: set = set()
for rid in (segment.entity_refs or []):
+33
View File
@@ -532,3 +532,36 @@ class ExportClipsTests(TestCase):
def test_export_empty_returns_400(self):
res = self.client.get(f"/api/projects/{self.project.id}/export-clips/")
self.assertEqual(res.status_code, 400)
class BaseAssetAdoptDeleteTests(TestCase):
"""显性采用/未采用 + 删除角色组。"""
def setUp(self):
from apps.projects.models import BaseAssetGroup
self.user = User.objects.create_user(username="bad", password="x")
self.team = Team.objects.create(name="BAD", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
self.proj = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
self.group = BaseAssetGroup.objects.create(project=self.proj, kind=BaseAssetGroup.Kind.PERSON, metadata={"label": "小明"})
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_set_adopt_state(self):
res = self.client.post(f"/api/projects/{self.proj.id}/set-base-asset-adopt/", {"group_id": str(self.group.id), "state": "unadopted"}, format="json")
self.assertEqual(res.status_code, 200)
self.group.refresh_from_db()
self.assertEqual(self.group.metadata.get("adopt"), "unadopted")
def test_set_adopt_invalid_state(self):
res = self.client.post(f"/api/projects/{self.proj.id}/set-base-asset-adopt/", {"group_id": str(self.group.id), "state": "x"}, format="json")
self.assertEqual(res.status_code, 400)
def test_delete_base_asset(self):
from apps.projects.models import BaseAssetGroup
res = self.client.post(f"/api/projects/{self.proj.id}/delete-base-asset/", {"group_id": str(self.group.id)}, format="json")
self.assertEqual(res.status_code, 200)
self.assertFalse(BaseAssetGroup.objects.filter(id=self.group.id).exists())
+32
View File
@@ -362,6 +362,38 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
promote_base_asset_stage_if_ready(project)
return Response(BaseAssetGroupSerializer(group).data)
@action(detail=True, methods=["post"], url_path="set-base-asset-adopt")
@transaction.atomic
def set_base_asset_adopt(self, request, pk=None):
"""显性「采用 / 未采用」某角色/场景组(metadata.adopt)。下游只取已采用的;脚本里没有的默认未采用,
但用户强制采用随他。默认态(不写 adopt)由前端按是否在脚本里推导,这里只存用户的覆盖。"""
project = self.get_object()
group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=request.data.get("group_id")).first()
if group is None:
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
state = request.data.get("state")
if state not in ("adopted", "unadopted"):
return Response({"detail": "state must be adopted/unadopted"}, status=status.HTTP_400_BAD_REQUEST)
meta = dict(group.metadata or {})
meta["adopt"] = state
group.metadata = meta
group.save(update_fields=["metadata", "updated_at"])
return Response(BaseAssetGroupSerializer(group).data)
@action(detail=True, methods=["post"], url_path="delete-base-asset")
@transaction.atomic
def delete_base_asset(self, request, pk=None):
"""删除某角色/场景资产组(连同它的三视图配套组一并删)。资产本身留在资产表,只移除项目内的组引用。"""
project = self.get_object()
group = BaseAssetGroup.objects.filter(project=project, id=request.data.get("group_id")).first()
if group is None:
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
# 连带删该角色立绘配套的三视图组(triview_of 指向本组采用的立绘)
if group.adopted_asset_id:
project.base_asset_groups.filter(metadata__triview_of=str(group.adopted_asset_id)).delete()
group.delete()
return Response({"deleted": True})
@action(detail=True, methods=["post"], url_path="attach-base-asset")
@transaction.atomic
def attach_base_asset(self, request, pk=None):
+2
View File
@@ -897,6 +897,8 @@ export function App() {
}, "故事板已生成")
}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
onGenerateTriview={async (portraitAssetId) => {
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
+8
View File
@@ -364,6 +364,14 @@ export const api = {
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
// 显性采用/未采用某角色/场景组(下游只取已采用)
setBaseAssetAdopt(projectId: string, payload: { group_id: string; state: "adopted" | "unadopted" }) {
return request(`/api/projects/${projectId}/set-base-asset-adopt/`, { method: "POST", body: JSON.stringify(payload) });
},
// 删除某角色/场景资产组(连带其三视图组)
deleteBaseAsset(projectId: string, groupId: string) {
return request(`/api/projects/${projectId}/delete-base-asset/`, { method: "POST", body: JSON.stringify({ group_id: groupId }) });
},
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
+16
View File
@@ -860,3 +860,19 @@
.pipeline-reextract { position: absolute; top: 14px; right: 28px; z-index: 5; }
/* 提取闸门单按钮:醒目主 CTA */
.eg-extract-btn { margin-top: 24px; min-width: 220px; }
/* C:角色/场景卡 采用/未采用 toggle */
.adopt-toggle { height: 22px; padding: 0 9px; border-radius: var(--r-pill); font-size: 11.5px; font-weight: 500; border: 1px solid transparent; cursor: pointer; white-space: nowrap; letter-spacing: .02em; }
.adopt-toggle.adopted { color: var(--accent-forest); background: var(--forest-bg); border-color: var(--forest-bd); }
.adopt-toggle.unadopted { color: var(--black-alpha-56); background: var(--black-alpha-4); border-color: var(--border-faint); }
.adopt-toggle:hover { border-color: var(--black-alpha-24); }
/* D:角色/场景卡 hover 删除 + 二次确认 */
.asset-card-2 { position: relative; }
.asset-card-del { position: absolute; top: 8px; right: 8px; z-index: 3; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border: none; border-radius: var(--r-sm); background: var(--black-alpha-48); color: var(--accent-white); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; }
.asset-card-del svg { width: 14px; height: 14px; }
.asset-card-2:hover .asset-card-del { opacity: 1; }
.asset-card-del:hover { background: var(--accent-crimson); }
.asset-card-del-confirm { position: absolute; top: 8px; right: 8px; z-index: 3; display: inline-flex; gap: 6px; }
.asset-card-del-confirm button { height: 24px; padding: 0 8px; border-radius: var(--r-sm); font-size: 11.5px; border: 1px solid var(--border-faint); cursor: pointer; }
.asset-card-del-confirm .del-yes { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.asset-card-del-confirm .del-no { background: var(--surface); color: var(--black-alpha-56); }
+38 -22
View File
@@ -469,6 +469,8 @@ export function PipelinePage(props: {
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise<unknown>;
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise<unknown>;
onDeleteBaseAsset: (groupId: string) => void | Promise<unknown>;
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
@@ -495,7 +497,7 @@ export function PipelinePage(props: {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
@@ -613,6 +615,18 @@ export function PipelinePage(props: {
const triGroupForAsset = (assetId?: string | null): BaseGroup | null =>
(assetId ? groupsByKind("person").find((g) => triviewOf(g) === assetId) || null : null);
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; group: BaseGroup; versions: string[] };
// 采用态:metadata.adopt 显性优先;否则按"实体名是否在脚本里"推导(在脚本→已采用,孤儿→未采用)
const scriptEntityNames = useMemo(
() => new Set(((project.metadata?.script_entities as Array<{ name?: string }>) ?? []).map((e) => (e?.name || "").trim()).filter(Boolean)),
[project.metadata?.script_entities]
);
function adoptStateOf(group: BaseGroup, name: string): "adopted" | "unadopted" {
const a = (group?.metadata as { adopt?: string } | undefined)?.adopt;
if (a === "adopted" || a === "unadopted") return a;
return scriptEntityNames.has((name || "").trim()) ? "adopted" : "unadopted";
}
// 删除二次确认:点删除先 arm(显"确认删除"),再点才真删;3s 自动撤销
const [delArmed, setDelArmed] = useState<string | null>(null);
// 一个 kind 下的实体列表:按 label 归并(同名取最新一组为代表),三视图组不计入
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
@@ -778,19 +792,7 @@ export function PipelinePage(props: {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [adDetail]);
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次)
const triAutoRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!adDetail || adDetail.kind !== "person") return;
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
if (!ent || isBusy(`addet-tri:${ent.key}`)) return;
const portraitAsset = adPortraitId || ent.group.adopted_asset;
if (!portraitAsset || triGroupForAsset(portraitAsset)) return;
if (triAutoRef.current.has(portraitAsset)) return;
triAutoRef.current.add(portraitAsset);
void genTriview(portraitAsset, `addet-tri:${ent.key}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adDetail, adPortraitId]);
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
const storyboards = project.storyboard_versions ?? [];
@@ -2589,7 +2591,7 @@ export function PipelinePage(props: {
) : (
<>
<div className="eg-title"> / </div>
<div className="mono eg-sub">// AI 认出角色 / 场景并出提示词,稍后你再逐个生成,不自动花钱</div>
<div className="mono eg-sub">// AI 认出角色 / 场景并出提示词,稍后你再逐个生成</div>
<button type="button" className="btn btn-primary btn-lg eg-extract-btn" onClick={() => void runExtract("only")}> / </button>
{extractErr && <div className="eg-err">{extractErr}</div>}
</>
@@ -2780,7 +2782,7 @@ export function PipelinePage(props: {
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => openActorReplaceSeed(kind, tag)}></button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
</div>
</div>
</div>
@@ -2801,6 +2803,17 @@ export function PipelinePage(props: {
const rs = kind === "person" && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
{/* D:hover 删除(角色/场景卡;商品主卡不给删)·二次确认 */}
{grp.id && (delArmed === grp.id ? (
<span className="asset-card-del-confirm" data-stop>
<button type="button" className="del-yes" onClick={(e) => { e.stopPropagation(); setDelArmed(null); void onDeleteBaseAsset(grp.id); }}></button>
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setDelArmed(null); }}></button>
</span>
) : (
<button type="button" className="asset-card-del" data-stop title="删除该角色/场景" onClick={(e) => { e.stopPropagation(); setDelArmed(grp.id); window.setTimeout(() => setDelArmed((v) => (v === grp.id ? 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${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
@@ -2820,10 +2833,14 @@ export function PipelinePage(props: {
onSubmit={() => submitAssetReview(grp.adopted_asset!)}
/>
)}
{/* C:采用/未采用 toggle —— 下游只取已采用;脚本里没有的默认未采用,可点采用。需已有真实组 */}
{grp.id && grp.adopted_asset && (() => { const st = adoptStateOf(grp, entity.name); return (
<button className={`adopt-toggle ${st}`} type="button" data-stop title={st === "adopted" ? "已采用 · 点击改为未采用" : "未采用 · 点击采用(下游只取已采用的)"} onClick={(e) => { e.stopPropagation(); void onSetAdoptState(grp.id, st === "adopted" ? "unadopted" : "adopted"); }}>{st === "adopted" ? "已采用" : "未采用"}</button>
); })()}
</div>
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; if (kind === "person") void genPersonWithTri(p, entity.name, entBK); else void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}></button>
</div>
@@ -2848,7 +2865,7 @@ export function PipelinePage(props: {
<div className="info"><span className="mono">[ · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => guardGen(shots, () => goStage(3))}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
@@ -3565,9 +3582,8 @@ export function PipelinePage(props: {
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
async function regenPortrait() {
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
// 重跑立绘 → 追加新候选并采用,人物链式据新立绘生成它的三视图;场景只重生立绘
if (isPerson) await genPersonWithTri(prompt, entity!.name, pBK);
else await genBaseAsset("scene", prompt, entity!.name, pBK);
// 重跑立绘 → 追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK);
setAdPortraitId(null); setAdTriId(null);
}
async function regenTri() {
@@ -3637,7 +3653,7 @@ export function PipelinePage(props: {
{!triUrl && !busyTri && (
<div className="asset-detail-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
<span>,//</span>
<span>,//</span>
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI </button>
</div>
)}
+18
View File
@@ -0,0 +1,18 @@
import { chromium } from "playwright";
import path from "node:path"; import fs from "node:fs";
const OUT=path.resolve("../../../_qa_shots/adopt"); fs.mkdirSync(OUT,{recursive:true});
const b=await chromium.launch({headless:true}); const r={consoleErrors:[]};
const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage();
p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())}); p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message));
await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300);
await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026");
await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000});
await p.goto("http://localhost:5200/pipeline/bdbf39d3-1a89-4135-b1b7-26d3331a7021"); await p.waitForTimeout(2200);
await p.locator('a[data-stage="2"]').first().click().catch(()=>{}); await p.waitForTimeout(2200);
r.adoptToggles=await p.locator(".adopt-toggle").count();
r.toggleTexts=await p.locator(".adopt-toggle").allInnerTexts().catch(()=>[]);
// hover 第一张角色卡看删除按钮
const card=p.locator(".asset-card-2[data-asset-kind='person']").first();
if(await card.count()){ await card.hover(); await p.waitForTimeout(500); r.delBtnVisible=await card.locator(".asset-card-del").isVisible().catch(()=>false); }
await p.screenshot({path:path.join(OUT,"adopt-del.png"),fullPage:true});
await b.close(); console.log(JSON.stringify(r,null,2));
+21
View File
@@ -0,0 +1,21 @@
import { chromium } from "playwright";
import path from "node:path"; import fs from "node:fs";
const OUT=path.resolve("../../../_qa_shots/adopt"); fs.mkdirSync(OUT,{recursive:true});
const b=await chromium.launch({headless:true}); const r={};
const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage();
await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300);
await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026");
await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000});
await p.goto("http://localhost:5200/pipeline/bdbf39d3-1a89-4135-b1b7-26d3331a7021"); await p.waitForTimeout(2200);
await p.locator('a[data-stage="2"]').first().click().catch(()=>{}); await p.waitForTimeout(2200);
r.delBtnInDom=await p.locator(".asset-card-del").count();
// hover 含 toggle 的那张真实角色卡
const realCard=p.locator(".asset-card-2:has(.adopt-toggle)").first();
r.realCardCount=await realCard.count();
if(await realCard.count()){
await realCard.hover(); await p.waitForTimeout(400);
const opacity=await realCard.locator(".asset-card-del").evaluate(el=>getComputedStyle(el).opacity).catch(()=>"n/a");
r.delOpacityOnHover=opacity;
await realCard.screenshot({path:path.join(OUT,"card-hover.png")});
}
await b.close(); console.log(JSON.stringify(r,null,2));