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:
co-authored by
Claude Opus 4.8
parent
6560633959
commit
01d4f235ed
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user