From 01d4f235ed317fcadc4c6968fae8f4459c403eec Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 21:52:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=204=20=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E8=AF=8D=20Admin(=E5=B9=B3=E5=8F=B0=E5=8D=95=E5=B1=82)=20?= =?UTF-8?q?=E2=80=94=20QualityWord=20+=20=E7=94=9F=E6=88=90=E4=BE=A7?= =?UTF-8?q?=E8=AF=BB=E9=85=8D=E7=BD=AE(=E6=97=A0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=9B=9E=E8=90=BD)=20+=20=E8=B4=A8=E9=87=8F=E8=AF=8D=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: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 --- core/backend/apps/adminpanel/serializers.py | 8 ++ core/backend/apps/adminpanel/tests.py | 61 ++++++++ core/backend/apps/adminpanel/urls.py | 4 + core/backend/apps/adminpanel/views.py | 65 ++++++++- .../apps/ai/migrations/0009_qualityword.py | 31 ++++ core/backend/apps/ai/models.py | 25 ++++ core/backend/apps/ai/services.py | 32 ++++- core/frontend/src/admin-page.css | 51 +++++++ core/frontend/src/api.ts | 13 ++ core/frontend/src/routes/admin/admin-app.tsx | 4 + .../src/routes/admin/admin-quality.tsx | 134 ++++++++++++++++++ core/frontend/src/types.ts | 9 ++ core/qa/visual-parity/_admin-p4.mjs | 83 +++++++++++ docs/todo/admin-后台-进度.md | 20 +++ 14 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 core/backend/apps/ai/migrations/0009_qualityword.py create mode 100644 core/frontend/src/routes/admin/admin-quality.tsx create mode 100644 core/qa/visual-parity/_admin-p4.mjs diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index cb2f9b3..ea099ab 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -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): diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 5e5ee83..fc2db71 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index df00bbd..e161e61 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -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//toggle/", admin_user_toggle, name="admin-user-toggle"), path("users//reset-password/", admin_user_reset_password, name="admin-user-reset-password"), + path("quality-words/", admin_quality_words, name="admin-quality-words"), + path("quality-words//", admin_quality_word_detail, name="admin-quality-word-detail"), ] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index d17e45f..a6c00f9 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -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) diff --git a/core/backend/apps/ai/migrations/0009_qualityword.py b/core/backend/apps/ai/migrations/0009_qualityword.py new file mode 100644 index 0000000..79e0b42 --- /dev/null +++ b/core/backend/apps/ai/migrations/0009_qualityword.py @@ -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')], + }, + ), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index 65ab225..8d816d0 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -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}" + diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 9951667..117b1f5 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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) diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 4e6772c..cd2d6b0 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -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); } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 03577df..9c7227e 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1,4 +1,5 @@ import type { + AdminQualityWord, AdminTeam, AdminTeamDetail, AdminUser, @@ -564,5 +565,17 @@ export const adminApi = { }, resetUserPassword(id: string, password: string) { return request(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) }); + }, + qualityWords() { + return request("/api/admin/quality-words/"); + }, + createQualityWord(payload: { stage: string; text: string; sort?: number; slot?: string }) { + return request("/api/admin/quality-words/", { method: "POST", body: JSON.stringify(payload) }); + }, + updateQualityWord(id: string, payload: { text?: string; sort?: number; enabled?: boolean }) { + return request(`/api/admin/quality-words/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, + deleteQualityWord(id: string) { + return request(`/api/admin/quality-words/${id}/`, { method: "DELETE" }); } }; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index b248481..78bdc23 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -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 ; } + if (section.slug === "quality") { + return ; + } // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-quality.tsx b/core/frontend/src/routes/admin/admin-quality.tsx new file mode 100644 index 0000000..a35a829 --- /dev/null +++ b/core/frontend/src/routes/admin/admin-quality.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [editMode, setEditMode] = useState(false); + const [adding, setAdding] = useState>({}); + + 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 ( + <> +
+
+

质量词

+
// 平台单层 · 各生成阶段的画质 / 风格词,留空则用默认
+
+
+ +
+
+ + {loading ? ( +

加载中…

// fetching quality words

+ ) : ( +
+ {STAGES.map((s) => { + const ws = words.filter((w) => w.stage === s.key); + return ( +
+
+ {s.label} + // {s.key} +
+ {ws.length === 0 && !editMode &&
未配置 · 使用默认质量词
} +
+ {ws.map((w) => + editMode ? ( + + saveText(w, e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }} + /> + + + ) : ( + {w.text} + ) + )} + {editMode && ( + + setAdding((a) => ({ ...a, [s.key]: e.target.value }))} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void add(s.key); } }} + /> + + )} +
+
+ ); + })} +
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index a63c787..eb5eeb4 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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 = { count: number; diff --git a/core/qa/visual-parity/_admin-p4.mjs b/core/qa/visual-parity/_admin-p4.mjs new file mode 100644 index 0000000..18b332f --- /dev/null +++ b/core/qa/visual-parity/_admin-p4.mjs @@ -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); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index 1afc1de..d993d1a 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -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//`,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。