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 rest_framework import serializers
|
||||||
|
|
||||||
from apps.accounts.models import Team, TeamMember, User
|
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):
|
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,
|
self.normal.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "newpass789"}, format="json").status_code,
|
||||||
403,
|
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 (
|
from .views import (
|
||||||
admin_invitations,
|
admin_invitations,
|
||||||
|
admin_quality_word_detail,
|
||||||
|
admin_quality_words,
|
||||||
admin_revoke_invitation,
|
admin_revoke_invitation,
|
||||||
admin_team_detail,
|
admin_team_detail,
|
||||||
admin_team_toggle,
|
admin_team_toggle,
|
||||||
@@ -21,4 +23,6 @@ urlpatterns = [
|
|||||||
path("users/", admin_users, name="admin-users"),
|
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>/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("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.models import Invitation, Team, User
|
||||||
from apps.accounts.permissions import IsPlatformAdmin
|
from apps.accounts.permissions import IsPlatformAdmin
|
||||||
from apps.accounts.serializers import InvitationSerializer
|
from apps.accounts.serializers import InvitationSerializer
|
||||||
|
from apps.ai.models import QualityWord
|
||||||
from apps.common.pagination import DefaultPagination
|
from apps.common.pagination import DefaultPagination
|
||||||
|
|
||||||
from .serializers import AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer
|
from .serializers import (
|
||||||
|
AdminTeamMemberSerializer,
|
||||||
|
AdminTeamSerializer,
|
||||||
|
AdminUserSerializer,
|
||||||
|
QualityWordSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _team_qs():
|
def _team_qs():
|
||||||
@@ -199,3 +205,60 @@ def admin_user_reset_password(request, user_id):
|
|||||||
target_name=user.username,
|
target_name=user.username,
|
||||||
)
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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:
|
def __str__(self) -> str:
|
||||||
return f"{self.task_type}:{self.status}:{self.id}"
|
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 ""
|
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:
|
def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
|
||||||
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。"""
|
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。"""
|
||||||
name = (getattr(product, "title", "") or "商品").strip()
|
name = (getattr(product, "title", "") or "商品").strip()
|
||||||
@@ -630,7 +650,7 @@ def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "
|
|||||||
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||||
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||||
]
|
]
|
||||||
lines.append("自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")
|
lines.append(quality_suffix("model_tryon", "自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。"))
|
||||||
if base_prompt and base_prompt.strip():
|
if base_prompt and base_prompt.strip():
|
||||||
lines.append(base_prompt.strip())
|
lines.append(base_prompt.strip())
|
||||||
return " ".join(lines)
|
return " ".join(lines)
|
||||||
@@ -662,7 +682,8 @@ def build_person_frontal_prompt(description: str = "") -> str:
|
|||||||
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
|
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
|
||||||
desc = (description or "").strip()
|
desc = (description or "").strip()
|
||||||
inner = f"{desc}," if desc else ""
|
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:
|
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:
|
if version.prompt:
|
||||||
lines.append(version.prompt.strip())
|
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)
|
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:
|
if extra:
|
||||||
script_text = (script_text + "\n" + extra).strip() if script_text else 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("【脚本】" + (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)
|
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-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-detail-subhead { font-size: 13px; font-weight: 500; color: var(--accent-black); margin: 8px 0 10px; }
|
||||||
.admin-inline-pill { margin-left: 8px; }
|
.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 {
|
import type {
|
||||||
|
AdminQualityWord,
|
||||||
AdminTeam,
|
AdminTeam,
|
||||||
AdminTeamDetail,
|
AdminTeamDetail,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
@@ -564,5 +565,17 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
resetUserPassword(id: string, password: string) {
|
resetUserPassword(id: string, password: string) {
|
||||||
return request<void>(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) });
|
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 { Team, User } from "../../types";
|
||||||
import type { NavigateFn } from "../route-config";
|
import type { NavigateFn } from "../route-config";
|
||||||
import { AdminInvitesPage } from "./admin-invites";
|
import { AdminInvitesPage } from "./admin-invites";
|
||||||
|
import { AdminQualityPage } from "./admin-quality";
|
||||||
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
|
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
|
||||||
|
|
||||||
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
|
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") {
|
if (section.slug === "users") {
|
||||||
return <AdminUsersPage notify={notify} />;
|
return <AdminUsersPage notify={notify} />;
|
||||||
}
|
}
|
||||||
|
if (section.slug === "quality") {
|
||||||
|
return <AdminQualityPage notify={notify} />;
|
||||||
|
}
|
||||||
// 其余模块在各自阶段替换此占位为真实页面
|
// 其余模块在各自阶段替换此占位为真实页面
|
||||||
return <AdminPlaceholder section={section} />;
|
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;
|
date_joined: string;
|
||||||
teams: { team_id: string; team_name: string; role: 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> = {
|
export type Paginated<T> = {
|
||||||
count: number;
|
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);
|
||||||
@@ -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`。
|
- 无头 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` 全绿。
|
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||||
- 视觉:团队/用户表格、绿/红状态 pill、详情弹窗,符合 restraint。
|
- 视觉:团队/用户表格、绿/红状态 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。
|
||||||
|
|||||||
Reference in New Issue
Block a user