feat(admin): Phase 4 质量词 Admin(平台单层) — QualityWord + 生成侧读配置(无配置回落) + 质量词页

后端:apps/ai QualityWord(stage/slot/text/sort/enabled)+ migration;services.py quality_words/quality_suffix,
4 个 builder 接入(person/model_tryon/storyboard/video)无配置回落原写死值零回归;adminpanel CRUD + 审计。
前端:adminApi quality 系列;Admin 质量词页(按 stage 分组卡 + 编辑模式开关 + 行内增改删 chip)。
测试:adminpanel 23 + accounts 22 = 45 单测过(CRUD/权限/生成读配置+回落/停用词不取);
无头 e2e _admin-p4.mjs 4 断言过 + 0 console error + live 库零残留;tsc+build 绿。
注:apps.ai 3 个 image_edit 路由测试失败为进场前既有(已 stash 比对 HEAD 同样失败),非本 Phase 引入。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 21:52:41 +08:00
co-authored by Claude Opus 4.8
parent 6560633959
commit 01d4f235ed
14 changed files with 535 additions and 5 deletions
+83
View File
@@ -0,0 +1,83 @@
// Phase 4 e2e:质量词 Admin —— 页面分组、编辑模式 chip 增删、持久化校验。
// ⚠ 跑在 live 测试库(生成链路会读它),用唯一标记词且**结束必清理**,绝不污染真实生成。
// build_* 读配置由后端单测 test_generation_reads_config_with_fallback 覆盖,这里只验 UI + 持久化。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://127.0.0.1:5188";
const API = process.env.API || "http://127.0.0.1:8010";
const OUT = path.resolve("output/admin");
fs.mkdirSync(OUT, { recursive: true });
const stamp = Date.now();
const WORD = `e2e画质${stamp}`;
async function apiLogin(u, p) {
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
return res.json();
}
const admin = await apiLogin("admin", "admin123");
const authH = { Authorization: `Token ${admin.token}` };
const listWords = async () => (await (await fetch(`${API}/api/admin/quality-words/`, { headers: authH })).json());
const r = { page: {}, add: {}, del: {}, consoleErrors: [], pass: false };
const browser = await chromium.launch({ headless: true });
const hook = (p, tag) => {
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
};
try {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
const p = await ctx.newPage(); hook(p, "quality");
await p.goto(BASE + "/admin/quality", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForSelector(".qw-stage", { timeout: 8000 }).catch(() => {});
r.page.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
r.page.stageCards = await p.locator(".qw-stage").count();
await p.screenshot({ path: path.join(OUT, "p4-quality-view.png"), fullPage: true });
// 进编辑模式 → person(第一张卡)添加唯一质量词
await p.locator(".page-head .actions .btn").click();
await p.waitForTimeout(300);
const personAdd = p.locator(".qw-stage:first-child .qw-add .qw-chip-input");
await personAdd.fill(WORD);
await personAdd.press("Enter");
await p.waitForTimeout(900);
const personChips = await p.locator(".qw-stage:first-child .qw-chip").count();
r.add.chipShown = personChips >= 1;
await p.screenshot({ path: path.join(OUT, "p4-after-add.png"), fullPage: true });
// API 校验持久化
const afterAdd = await listWords();
r.add.persisted = afterAdd.some((w) => w.stage === "person" && w.text === WORD);
// UI 删除该词(person 卡最后一个 chip 的 ×)
await p.locator(".qw-stage:first-child .qw-chip .qw-del").last().click();
await p.waitForTimeout(900);
const afterDel = await listWords();
r.del.removed = !afterDel.some((w) => w.stage === "person" && w.text === WORD);
await ctx.close();
} finally {
await browser.close();
// 兜底清理:删掉任何残留的 e2e 标记词(防止污染 live 生成)
const remaining = (await listWords()).filter((w) => w.text.startsWith("e2e画质"));
for (const w of remaining) {
await fetch(`${API}/api/admin/quality-words/${w.id}/`, { method: "DELETE", headers: authH });
}
r.cleanedUp = remaining.length;
}
const checks = {
pageLoads: r.page.title === "质量词" && r.page.stageCards === 4,
addShownAndPersisted: r.add.chipShown === true && r.add.persisted === true,
deleteRemoved: r.del.removed === true,
zeroConsoleErrors: r.consoleErrors.length === 0,
};
r.checks = checks;
r.pass = Object.values(checks).every(Boolean);
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "p4-summary.json"), JSON.stringify(r, null, 2));
process.exit(r.pass ? 0 : 1);