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
@@ -1,6 +1,14 @@
from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import QualityWord
class QualityWordSerializer(serializers.ModelSerializer):
class Meta:
model = QualityWord
fields = ["id", "stage", "slot", "text", "sort", "enabled", "created_at"]
read_only_fields = ["id", "created_at"]
class AdminTeamSerializer(serializers.ModelSerializer):
+61
View File
@@ -171,3 +171,64 @@ class AdminTeamUserApiTests(TestCase):
self.normal.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "newpass789"}, format="json").status_code,
403,
)
class AdminQualityWordTests(TestCase):
"""Phase 4:质量词 CRUD + 权限 + 生成侧读配置(无配置回落写死值,零回归)。"""
def setUp(self):
self.admin = User.objects.create_user(username="padmin4", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal4", password="x")
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_crud_and_permission_and_audit(self):
self.assertEqual(self.nc.get("/api/admin/quality-words/").status_code, 403)
self.assertEqual(self.nc.post("/api/admin/quality-words/", {"stage": "person", "text": "x"}, format="json").status_code, 403)
r = self.ac.post("/api/admin/quality-words/", {"stage": "person", "text": "赛博朋克风"}, format="json")
self.assertEqual(r.status_code, 201)
wid = r.data["id"]
self.assertTrue(AdminAuditLog.objects.filter(action="quality_word.create").exists())
lst = self.ac.get("/api/admin/quality-words/")
self.assertEqual(lst.status_code, 200)
self.assertTrue(any(w["text"] == "赛博朋克风" for w in lst.data))
up = self.ac.patch(f"/api/admin/quality-words/{wid}/", {"text": "国潮风"}, format="json")
self.assertEqual(up.status_code, 200)
self.assertEqual(up.data["text"], "国潮风")
self.assertEqual(self.ac.delete(f"/api/admin/quality-words/{wid}/").status_code, 204)
def test_invalid_stage_rejected(self):
r = self.ac.post("/api/admin/quality-words/", {"stage": "bogus", "text": "x"}, format="json")
self.assertEqual(r.status_code, 400)
def test_generation_reads_config_with_fallback(self):
from apps.ai.models import QualityWord
from apps.ai.services import build_person_frontal_prompt
# 无配置 → 回落写死值
self.assertIn("纯色背景", build_person_frontal_prompt("小姐姐"))
# 配置后 → 生成用新词,描述仍保留
QualityWord.objects.create(stage="person", text="赛博朋克风", sort=0)
QualityWord.objects.create(stage="person", text="霓虹光", sort=1)
out = build_person_frontal_prompt("小姐姐")
self.assertIn("赛博朋克风", out)
self.assertIn("霓虹光", out)
self.assertIn("小姐姐", out)
self.assertNotIn("纯色背景", out)
def test_disabled_word_not_used(self):
from apps.ai.models import QualityWord
from apps.ai.services import quality_words
QualityWord.objects.create(stage="video", text="启用词", enabled=True)
QualityWord.objects.create(stage="video", text="停用词", enabled=False)
words = quality_words("video")
self.assertIn("启用词", words)
self.assertNotIn("停用词", words)
+4
View File
@@ -2,6 +2,8 @@ from django.urls import path
from .views import (
admin_invitations,
admin_quality_word_detail,
admin_quality_words,
admin_revoke_invitation,
admin_team_detail,
admin_team_toggle,
@@ -21,4 +23,6 @@ urlpatterns = [
path("users/", admin_users, name="admin-users"),
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
path("quality-words/", admin_quality_words, name="admin-quality-words"),
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
]
+64 -1
View File
@@ -10,9 +10,15 @@ from apps.accounts.audit import log_admin_action
from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import QualityWord
from apps.common.pagination import DefaultPagination
from .serializers import AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer
from .serializers import (
AdminTeamMemberSerializer,
AdminTeamSerializer,
AdminUserSerializer,
QualityWordSerializer,
)
def _team_qs():
@@ -199,3 +205,60 @@ def admin_user_reset_password(request, user_id):
target_name=user.username,
)
return Response(status=status.HTTP_204_NO_CONTENT)
# ─────────────────────────── 质量词(平台单层)───────────────────────────
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_quality_words(request):
"""GET 列全部质量词(配置量小,不分页,前端按 stage 分组);POST 新增一条。"""
if request.method == "GET":
qs = QualityWord.objects.all().order_by("stage", "sort", "created_at")
return Response(QualityWordSerializer(qs, many=True).data)
serializer = QualityWordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
log_admin_action(
request,
"quality_word.create",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
after={"stage": obj.stage, "text": obj.text},
)
return Response(QualityWordSerializer(obj).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsPlatformAdmin])
def admin_quality_word_detail(request, word_id):
obj = QualityWord.objects.filter(id=word_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if request.method == "DELETE":
log_admin_action(
request,
"quality_word.delete",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
before={"stage": obj.stage, "text": obj.text},
)
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
before_text = obj.text
serializer = QualityWordSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(
request,
"quality_word.update",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
before={"text": before_text},
after={"text": obj.text},
)
return Response(QualityWordSerializer(obj).data)
@@ -0,0 +1,31 @@
# Generated by Django 5.1.15 on 2026-06-19 13:43
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0008_aitask_ai_aitask_team_id_570138_idx'),
]
operations = [
migrations.CreateModel(
name='QualityWord',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('stage', models.CharField(choices=[('person', '人物立绘'), ('model_tryon', '模特上身图'), ('storyboard', '故事板'), ('video', '视频')], max_length=24)),
('slot', models.CharField(default='quality', max_length=24)),
('text', models.CharField(max_length=200)),
('sort', models.IntegerField(default=0)),
('enabled', models.BooleanField(default=True)),
],
options={
'ordering': ['stage', 'sort', 'created_at'],
'indexes': [models.Index(fields=['stage', 'slot'], name='ai_qualityw_stage_891a12_idx')],
},
),
]
+25
View File
@@ -107,3 +107,28 @@ class AITask(TeamOwnedModel):
def __str__(self) -> str:
return f"{self.task_type}:{self.status}:{self.id}"
class QualityWord(TimeStampedModel):
"""平台单层质量词配置:按生成阶段(stage)+ 槽位(slot)挂若干质量词,
生成侧拼提示词时优先读取本配置;**无配置则回落各 builder 的写死值**(零回归)。
全平台共用一套(不挂 team),团队级覆盖以后再说。"""
class Stage(models.TextChoices):
PERSON = "person", "人物立绘"
MODEL_TRYON = "model_tryon", "模特上身图"
STORYBOARD = "storyboard", "故事板"
VIDEO = "video", "视频"
stage = models.CharField(max_length=24, choices=Stage.choices)
slot = models.CharField(max_length=24, default="quality")
text = models.CharField(max_length=200)
sort = models.IntegerField(default=0)
enabled = models.BooleanField(default=True)
class Meta:
ordering = ["stage", "sort", "created_at"]
indexes = [models.Index(fields=["stage", "slot"])]
def __str__(self) -> str:
return f"{self.stage}/{self.slot}/{self.text}"
+28 -4
View File
@@ -599,6 +599,26 @@ def _product_cover_url(product) -> str:
return ""
def quality_words(stage: str, slot: str = "quality") -> list[str]:
"""平台单层质量词配置(QualityWord)。无配置/表不存在 → 返回 [],调用方回落写死值,保证零回归。"""
try:
from apps.ai.models import QualityWord
return list(
QualityWord.objects.filter(stage=stage, slot=slot, enabled=True)
.order_by("sort", "created_at")
.values_list("text", flat=True)
)
except Exception: # noqa: BLE001 — 配置读取失败绝不阻断生成
return []
def quality_suffix(stage: str, fallback: str, slot: str = "quality", sep: str = "") -> str:
"""取该阶段配置的质量词拼成尾串;无配置回落到 fallback(各 builder 的原写死值)。"""
words = quality_words(stage, slot)
return sep.join(words) if words else fallback
def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。"""
name = (getattr(product, "title", "") or "商品").strip()
@@ -630,7 +650,7 @@ def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
]
lines.append("自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")
lines.append(quality_suffix("model_tryon", "自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。"))
if base_prompt and base_prompt.strip():
lines.append(base_prompt.strip())
return " ".join(lines)
@@ -662,7 +682,8 @@ def build_person_frontal_prompt(description: str = "") -> str:
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
desc = (description or "").strip()
inner = f"{desc}" if desc else ""
return f"电商真人模特,氛围正面全身照,{inner}自然妆容,柔和影棚光,真实质感,单人,纯色背景"
tail = quality_suffix("person", "自然妆容,柔和影棚光,真实质感,单人,纯色背景")
return f"电商真人模特,氛围正面全身照,{inner}{tail}"
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
@@ -896,7 +917,7 @@ def build_storyboard_frame_prompt(project, version, segment) -> str:
]
if version.prompt:
lines.append(version.prompt.strip())
lines.append("电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。")
lines.append(quality_suffix("storyboard", "电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。"))
return "\n".join(line for line in lines if line)
@@ -923,7 +944,10 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt:
if extra:
script_text = (script_text + "\n" + extra).strip() if script_text else extra
lines.append("【脚本】" + (script_text or f"{video_segment.sort_order + 1}"))
lines.append(f"{video_segment.target_duration_seconds}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。")
lines.append(
f"{video_segment.target_duration_seconds}s · "
+ quality_suffix("video", "9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。")
)
return "\n".join(line for line in lines if line)
+51
View File
@@ -144,3 +144,54 @@
.admin-detail-meta .v { font-size: 14px; color: var(--accent-black); }
.admin-detail-subhead { font-size: 13px; font-weight: 500; color: var(--accent-black); margin: 8px 0 10px; }
.admin-inline-pill { margin-left: 8px; }
/* ── 质量词:按 stage 分组 + 编辑模式 chip ── */
.qw-stages { display: flex; flex-direction: column; gap: 16px; }
.qw-stage { padding: 18px 20px; }
.qw-stage-h { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
.qw-stage-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }
.qw-stage-hint { font-size: 11px; color: var(--black-alpha-48); }
.qw-empty { font-size: 12px; color: var(--black-alpha-48); }
.qw-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.qw-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
background: var(--heat-12);
border: 1px solid var(--heat-20);
border-radius: var(--r-pill);
font-size: 12.5px;
color: var(--heat);
}
.qw-chip.editing { background: var(--surface); border-color: var(--border-faint); padding: 0 6px 0 10px; }
.qw-chip-input {
border: 0;
background: transparent;
outline: none;
font: inherit;
color: var(--accent-black);
width: 110px;
height: 26px;
font-size: 12.5px;
}
.qw-add .qw-chip-input {
width: 150px;
border: 1px dashed var(--black-alpha-24);
border-radius: var(--r-pill);
padding: 0 12px;
height: 28px;
}
.qw-del {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
border: 0;
background: transparent;
color: var(--black-alpha-48);
cursor: pointer;
border-radius: 50%;
}
.qw-del:hover { color: var(--accent-crimson); background: var(--black-alpha-4); }
+13
View File
@@ -1,4 +1,5 @@
import type {
AdminQualityWord,
AdminTeam,
AdminTeamDetail,
AdminUser,
@@ -564,5 +565,17 @@ export const adminApi = {
},
resetUserPassword(id: string, password: string) {
return request<void>(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) });
},
qualityWords() {
return request<AdminQualityWord[]>("/api/admin/quality-words/");
},
createQualityWord(payload: { stage: string; text: string; sort?: number; slot?: string }) {
return request<AdminQualityWord>("/api/admin/quality-words/", { method: "POST", body: JSON.stringify(payload) });
},
updateQualityWord(id: string, payload: { text?: string; sort?: number; enabled?: boolean }) {
return request<AdminQualityWord>(`/api/admin/quality-words/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteQualityWord(id: string) {
return request<void>(`/api/admin/quality-words/${id}/`, { method: "DELETE" });
}
};
@@ -4,6 +4,7 @@ import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell"
import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config";
import { AdminInvitesPage } from "./admin-invites";
import { AdminQualityPage } from "./admin-quality";
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
@@ -174,6 +175,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "users") {
return <AdminUsersPage notify={notify} />;
}
if (section.slug === "quality") {
return <AdminQualityPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />;
}
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminQualityWord } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
// 与后端 QualityWord.Stage 对齐(生成侧已接入这 4 个阶段)
const STAGES = [
{ key: "person", label: "人物立绘" },
{ key: "model_tryon", label: "模特上身图" },
{ key: "storyboard", label: "故事板" },
{ key: "video", label: "视频" }
];
export function AdminQualityPage({ notify }: { notify: Notify }) {
const [words, setWords] = useState<AdminQualityWord[]>([]);
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [adding, setAdding] = useState<Record<string, string>>({});
const load = useCallback(async () => {
setLoading(true);
try {
setWords(await adminApi.qualityWords());
} catch {
notify("error", "加载质量词失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { void load(); }, [load]);
async function add(stage: string) {
const text = (adding[stage] || "").trim();
if (!text) return;
try {
await adminApi.createQualityWord({ stage, text });
setAdding((a) => ({ ...a, [stage]: "" }));
notify("success", "已添加质量词");
await load();
} catch {
notify("error", "添加失败");
}
}
async function saveText(w: AdminQualityWord, text: string) {
const t = text.trim();
if (!t || t === w.text) return;
try {
await adminApi.updateQualityWord(w.id, { text: t });
notify("success", "已更新");
await load();
} catch {
notify("error", "更新失败");
}
}
async function del(w: AdminQualityWord) {
try {
await adminApi.deleteQualityWord(w.id);
notify("success", "已删除");
await load();
} catch {
notify("error", "删除失败");
}
}
return (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 平台单层</span> · 各生成阶段的画质 / 风格词,留空则用默认</div>
</div>
<div className="actions">
<button className={`btn${editMode ? " btn-primary" : ""}`} type="button" onClick={() => setEditMode((v) => !v)}>
{editMode ? "完成编辑" : "编辑模式"}
</button>
</div>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3></h3><p>// fetching quality words</p></div>
) : (
<div className="qw-stages">
{STAGES.map((s) => {
const ws = words.filter((w) => w.stage === s.key);
return (
<div key={s.key} className="card-hard qw-stage">
<div className="qw-stage-h">
<span className="qw-stage-title">{s.label}</span>
<span className="mono qw-stage-hint">// {s.key}</span>
</div>
{ws.length === 0 && !editMode && <div className="qw-empty mono"> · 使</div>}
<div className="qw-chips">
{ws.map((w) =>
editMode ? (
<span key={w.id} className="qw-chip editing">
<input
className="qw-chip-input"
defaultValue={w.text}
onBlur={(e) => saveText(w, e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/>
<button className="qw-del" type="button" aria-label="删除" onClick={() => del(w)}><X size={12} /></button>
</span>
) : (
<span key={w.id} className="qw-chip">{w.text}</span>
)
)}
{editMode && (
<span className="qw-add">
<input
className="qw-chip-input"
placeholder="+ 添加质量词"
value={adding[s.key] || ""}
onChange={(e) => setAdding((a) => ({ ...a, [s.key]: e.target.value }))}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void add(s.key); } }}
/>
</span>
)}
</div>
</div>
);
})}
</div>
)}
</>
);
}
+9
View File
@@ -73,6 +73,15 @@ export type AdminUser = {
date_joined: string;
teams: { team_id: string; team_name: string; role: string }[];
};
export type AdminQualityWord = {
id: string;
stage: string;
slot: string;
text: string;
sort: number;
enabled: boolean;
created_at: string;
};
export type Paginated<T> = {
count: number;
+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);
+20
View File
@@ -104,3 +104,23 @@
- 无头 e2e:`_admin-p3.mjs`(5188,用一次性 P3 团队不动 demo)—— **7 断言全过 + 0 console error**:团队页+搜索+详情弹窗;**UI 停用团队→该成员 API 登录 400→启用→200**;用户页超管标记;**UI 改密→新密码登录 200**。截图 `output/admin/p3-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
- 视觉:团队/用户表格、绿/红状态 pill、详情弹窗,符合 restraint。
---
## ✅ Phase 4 · 质量词 Admin(平台单层)— 完成 2026-06-19
**后端**
- 新模型 `apps/ai/models.QualityWord`(stage + slot + text + sort + enabled,平台单层无 team)+ migration `0009_qualityword`(已 apply 测试 MySQL)。
- 生成侧 `services.py``quality_words()/quality_suffix()`,4 个 builder 接入(`build_person_frontal_prompt` / `build_model_tryon_prompt_refs` / `build_storyboard_frame_prompt` / `build_video_segment_prompt`)——**无配置回落原写死值,零回归**。
- adminpanel CRUD:`GET/POST quality-words/` + `PATCH/DELETE quality-words/<id>/`,IsPlatformAdmin + 审计(create/update/delete);QualityWordSerializer。
**前端**
- `adminApi` qualityWords/create/update/delete;`AdminQualityWord` 类型。
- Admin「质量词」页(`admin-quality.tsx`):按 stage(人物立绘/模特上身图/故事板/视频)分组卡片 + **编辑模式开关防误删** + 行内增(回车)/改(失焦保存)/删(× 仅编辑态显示);橙色 chip + 虚线添加框(仅 token)。
**测试底座(全过)**
- 后端单测:`apps.adminpanel` **23 项 + accounts 22 = 45 OK**(质量词 CRUD/权限 403/非法 stage 拒/**生成侧读配置 + 无配置回落 + 描述保留**/停用词不取用)。
- ⚠ `apps.ai` 有 3 个 `StandaloneImageReferenceTests`(image_edit 路由)失败 —— **已 git stash 比对确认:HEAD(未改 services.py)同样失败,为进场前既有,非本 Phase 引入**(本 Phase 只改提示词字符串内容,不动 provider 路由)。
- 无头 e2e:`_admin-p4.mjs`(5188,跑 live 库故用唯一标记词且**结束兜底清理**)—— **4 断言全过 + 0 console error**:页面 4 卡、编辑模式加词→chip 显示+API 持久化、删词→API 移除;live 库零残留。截图 `output/admin/p4-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
- 视觉:分组卡 + 橙 chip + 编辑态,符合 restraint。