feat(models): 期1 模特库地基 — Model 顶级实体 + 模特库页 + 审核范围扩展

后端:
- 新增 Model(模特)团队级实体:形象图+三视图+声线(声线尾期),官方模板标签,软删
- Asset.Category 枚举一次加全(model_portrait/tri_view/voice/model_tryon/platform_kit/free_create/storyboard),逐期接线
- 送审范围 person → REVIEW_CATEGORIES{person,tri_view,storyboard}(review.py + adminpanel 队列),图片趴不送审;当前行为不变(forward-compat)
- /api/models/ ModelLibraryViewSet:本团队∪官方模板、官方/我的 tab、真人上传、软删(官方不可删)、跨团队资产引用防越权
- backfill_models 命令把历史成套模特(kind=model + 项目流命名配对)归集成 Model,dry-run 默认

前端:
- 左菜单新增「模特库」顶级入口(person 图标)+ 路由 /models
- 模特库页:全部/官方模板/我的模特 tab + 真人上传 + 模特卡(形象图+三视图缩略+官方标签),仅用 design token

测试:assets 10/10 绿;tsc+build 全绿;无头走查 0 console error(全部5/官方3/我的2,官方标签达标)
基线既有 7 失败(ai/projects provider mock 漂移)零新增

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-20 22:53:46 +08:00
co-authored by Claude Opus 4.8
parent 1c9143ce61
commit 44e90233ff
23 changed files with 1003 additions and 12 deletions
+1
View File
@@ -10,6 +10,7 @@ urlpatterns = [
path("api/auth/", include("apps.accounts.urls")), path("api/auth/", include("apps.accounts.urls")),
path("api/products/", include("apps.products.urls")), path("api/products/", include("apps.products.urls")),
path("api/assets/", include("apps.assets.urls")), path("api/assets/", include("apps.assets.urls")),
path("api/models/", include("apps.assets.model_urls")),
path("api/projects/", include("apps.projects.urls")), path("api/projects/", include("apps.projects.urls")),
path("api/billing/", include("apps.billing.urls")), path("api/billing/", include("apps.billing.urls")),
path("api/ai/", include("apps.ai.urls")), path("api/ai/", include("apps.ai.urls")),
+4 -4
View File
@@ -289,9 +289,9 @@ _REVIEW_STATUSES = {"", "processing", "active", "failed"}
@api_view(["GET"]) @api_view(["GET"])
@permission_classes([IsPlatformAdmin]) @permission_classes([IsPlatformAdmin])
def admin_asset_reviews(request): def admin_asset_reviews(request):
"""跨团队真人(person)资产审核队列。?review_status=none|processing|active|failed 过滤(none=未送审)。""" """跨团队送审资产审核队列(角色定妆照/三视图/分镜图)。?review_status=none|processing|active|failed 过滤(none=未送审)。"""
qs = ( qs = (
Asset.objects.filter(category=Asset.Category.PERSON, is_deleted=False) Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, is_deleted=False)
.select_related("team") .select_related("team")
.prefetch_related("files") .prefetch_related("files")
.order_by("-created_at") .order_by("-created_at")
@@ -314,7 +314,7 @@ def admin_asset_reviews(request):
def admin_asset_reviews_submit(request): def admin_asset_reviews_submit(request):
"""批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。""" """批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。"""
ids = request.data.get("asset_ids") or [] ids = request.data.get("asset_ids") or []
assets = list(Asset.objects.filter(id__in=ids, category=Asset.Category.PERSON, is_deleted=False)) assets = list(Asset.objects.filter(id__in=ids, category__in=Asset.REVIEW_CATEGORIES, is_deleted=False))
for asset in assets: for asset in assets:
submit_asset_for_review(asset) submit_asset_for_review(asset)
statuses = {str(a.id): a.review_status for a in Asset.objects.filter(id__in=ids)} statuses = {str(a.id): a.review_status for a in Asset.objects.filter(id__in=ids)}
@@ -333,7 +333,7 @@ def admin_asset_reviews_submit(request):
def admin_asset_reviews_poll(request): def admin_asset_reviews_poll(request):
"""轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。""" """轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。"""
ids = request.data.get("asset_ids") ids = request.data.get("asset_ids")
qs = Asset.objects.filter(category=Asset.Category.PERSON, review_status="processing", is_deleted=False) qs = Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False)
if ids: if ids:
qs = qs.filter(id__in=ids) qs = qs.filter(id__in=ids)
statuses = {} statuses = {}
@@ -0,0 +1,145 @@
"""把历史「成套模特(形象图 + 三视图)」归集成 Model 实体,顺带报告/清理孤儿错图。
两条历史来源都收编:
1) 模特库生成(model_library.generate_model):metadata.kind="model" 的 PERSON 资产,
成对的 view=frontal / view=three_view 共享同一 brief、同团队 → 按 brief 配对。
2) 项目立绘流(run_triview_task):三视图资产带 metadata.triview_of=<立绘 asset id> → 立绘=形象图、它=三视图。
幂等(局部):已被某个 Model 引用过的 portrait/triview 资产会跳过,同一张图不会被重复归组。
注意这是 **一次性数据工具**:同名前缀若有多余的重生版本(例如同项目多次出图),再次运行会把剩余版本另配成新组。
所以跑一次即可,别反复跑。默认 **dry-run** 只打印计划;--apply 才真正写库。清理孤儿用 --soft-delete-orphans(只置 is_deleted,可逆)。
"""
from collections import defaultdict
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.assets.models import Asset, Model
class Command(BaseCommand):
help = "把历史成套模特(形象图+三视图)归集成 Model 实体;报告并可软删孤儿错图。"
def add_arguments(self, parser):
parser.add_argument("--apply", action="store_true", help="真正写库(默认 dry-run 只打印)")
parser.add_argument(
"--soft-delete-orphans",
action="store_true",
help="把无法配对、且无文件的 model 类孤儿资产置 is_deleted=True(可逆)",
)
def handle(self, *args, **opts):
apply = opts["apply"]
soft_del = opts["soft_delete_orphans"]
self.stdout.write(self.style.WARNING("=== backfill_models %s ===" % ("APPLY" if apply else "DRY-RUN")))
# 已被 Model 引用的资产(幂等跳过)
used = set()
for pid, tid in Model.objects.values_list("portrait_asset_id", "triview_asset_id"):
if pid:
used.add(str(pid))
if tid:
used.add(str(tid))
plans: list[dict] = [] # {name, portrait, triview, source}
# —— 来源 2:项目立绘流(triview_of) ——
triviews = Asset.objects.filter(metadata__triview_of__isnull=False, is_deleted=False)
for tv in triviews:
portrait_id = str((tv.metadata or {}).get("triview_of") or "")
if not portrait_id or str(tv.id) in used or portrait_id in used:
continue
portrait = Asset.objects.filter(id=portrait_id, is_deleted=False).first()
if portrait is None:
continue
plans.append(
{"name": (portrait.name or "模特").split("·")[0][:255], "portrait": portrait, "triview": tv, "source": "project"}
)
used.update({str(tv.id), portrait_id})
# —— 来源 1:模特库生成(kind=model,按 team+brief 配对 frontal/three_view) ——
groups: dict[tuple, dict[str, list[Asset]]] = defaultdict(lambda: {"frontal": [], "three_view": []})
models_qs = Asset.objects.filter(metadata__kind="model", is_deleted=False).order_by("created_at")
for a in models_qs:
if str(a.id) in used:
continue
view = (a.metadata or {}).get("view")
if view in ("frontal", "three_view"):
groups[(a.team_id, (a.metadata or {}).get("brief", ""))][view].append(a)
for (team_id, brief), bucket in groups.items():
frontals, tvs = bucket["frontal"], bucket["three_view"]
for portrait, triview in zip(frontals, tvs):
if str(portrait.id) in used or str(triview.id) in used:
continue
plans.append(
{"name": (portrait.name or brief or "模特").split("·")[0][:255], "portrait": portrait, "triview": triview, "source": "model_lib"}
)
used.update({str(portrait.id), str(triview.id)})
# —— 来源 3:项目立绘流按命名后缀配对(立绘 "X-person" ↔ 三视图 "X-三视图",同前缀同团队) ——
# 历史项目流的 person 资产没写 triview_of,但命名成对;按名前缀收编成套的。
PORT_SUF = ("-person", "-立绘")
TRI_SUF = ("-三视图", "·三视图")
def _strip(name: str):
for s in TRI_SUF:
if name.endswith(s):
return name[: -len(s)], "tri"
for s in PORT_SUF:
if name.endswith(s):
return name[: -len(s)], "port"
return None, None
named: dict[tuple, dict[str, Asset]] = defaultdict(dict)
for a in Asset.objects.filter(category="person", is_deleted=False).order_by("created_at"):
if str(a.id) in used:
continue
prefix, role = _strip(a.name or "")
if role is None:
continue
slot = named[(a.team_id, prefix)]
slot.setdefault(role, a) # 同前缀多版本取最早一张,避免错配
for (team_id, prefix), slot in named.items():
portrait, triview = slot.get("port"), slot.get("tri")
if not portrait or not triview or str(portrait.id) in used or str(triview.id) in used:
continue
plans.append({"name": prefix.strip(" ·-")[:255] or "模特", "portrait": portrait, "triview": triview, "source": "named"})
used.update({str(portrait.id), str(triview.id)})
# —— 孤儿:model 类资产没进任何配对(疑似早期错图 / 半成品) ——
orphans = [
a for a in models_qs
if str(a.id) not in used and (a.metadata or {}).get("view") in ("frontal", "three_view")
]
no_file_orphans = [a for a in orphans if not a.files.exists()]
# —— 打印计划 ——
self.stdout.write(f" 待建 Model:{len(plans)}")
for p in plans:
self.stdout.write(f" [{p['source']}] {p['name']} portrait={p['portrait'].id} triview={p['triview'].id}")
self.stdout.write(f" 孤儿(model 类未配对):{len(orphans)};其中无文件:{len(no_file_orphans)}")
if not apply:
self.stdout.write(self.style.WARNING("DRY-RUN 结束,未写库。加 --apply 执行。"))
return
created = 0
with transaction.atomic():
for p in plans:
Model.objects.create(
team_id=p["portrait"].team_id,
created_by_id=p["portrait"].created_by_id,
name=p["name"],
source=Model.Source.AI,
portrait_asset=p["portrait"],
triview_asset=p["triview"],
metadata={"backfill": p["source"]},
)
created += 1
soft_deleted = 0
if soft_del:
for a in no_file_orphans:
a.is_deleted = True
a.save(update_fields=["is_deleted", "updated_at"])
soft_deleted += 1
self.stdout.write(self.style.SUCCESS(f"完成:建 {created} 个 Model;软删孤儿 {soft_deleted if soft_del else 0} 张。"))
@@ -0,0 +1,46 @@
# Generated by Django 5.1.15 on 2026-06-20 14:32
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_invitation_kind_alter_invitation_team'),
('assets', '0005_asset_assets_asse_team_id_4b1b57_idx_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='asset',
name='category',
field=models.CharField(choices=[('person', 'Person'), ('scene', 'Scene'), ('product_image', 'Product Image'), ('video_clip', 'Video Clip'), ('final_video', 'Final Video'), ('upload', 'Upload'), ('uncategorized', 'Uncategorized'), ('model_portrait', 'Model Portrait'), ('tri_view', 'Tri View'), ('voice', 'Voice'), ('model_tryon', 'Model Try-on'), ('platform_kit', 'Platform Kit'), ('free_create', 'Free Create'), ('storyboard', 'Storyboard')], default='uncategorized', max_length=32),
),
migrations.CreateModel(
name='Model',
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)),
('name', models.CharField(max_length=255)),
('is_official', models.BooleanField(default=False)),
('source', models.CharField(choices=[('ai', 'AI Generated'), ('upload', 'Upload')], default='ai', max_length=16)),
('description', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('is_deleted', models.BooleanField(default=False)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL)),
('portrait_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_portrait', to='assets.asset')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='accounts.team')),
('triview_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_triview', to='assets.asset')),
('voice_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_voice', to='assets.asset')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['team', 'is_official', '-created_at'], name='assets_mode_team_id_0caf77_idx')],
},
),
]
+9
View File
@@ -0,0 +1,9 @@
"""模特库路由(顶级实体,挂在 /api/models/)。与 AssetViewSet(/api/assets/)分开,避免 "" 前缀 pk 冲突。"""
from rest_framework.routers import DefaultRouter
from .views import ModelLibraryViewSet
router = DefaultRouter()
router.register("", ModelLibraryViewSet, basename="model")
urlpatterns = router.urls
+51 -1
View File
@@ -18,13 +18,24 @@ class Asset(TeamOwnedModel):
SYSTEM = "system", "System" SYSTEM = "system", "System"
class Category(models.TextChoices): class Category(models.TextChoices):
PERSON = "person", "Person" PERSON = "person", "Person" # 视频趴·角色定妆照(送审)
SCENE = "scene", "Scene" SCENE = "scene", "Scene"
PRODUCT_IMAGE = "product_image", "Product Image" PRODUCT_IMAGE = "product_image", "Product Image"
VIDEO_CLIP = "video_clip", "Video Clip" VIDEO_CLIP = "video_clip", "Video Clip"
FINAL_VIDEO = "final_video", "Final Video" FINAL_VIDEO = "final_video", "Final Video"
UPLOAD = "upload", "Upload" UPLOAD = "upload", "Upload"
UNCATEGORIZED = "uncategorized", "Uncategorized" UNCATEGORIZED = "uncategorized", "Uncategorized"
# ↓ 模特库 + 资产模型重构(2026-06-20)新增,逐期接线。
MODEL_PORTRAIT = "model_portrait", "Model Portrait" # 模特形象图(图片域·不送审)
TRI_VIEW = "tri_view", "Tri View" # 三视图(送审)
VOICE = "voice", "Voice" # 模特声线(占位·尾期)
MODEL_TRYON = "model_tryon", "Model Try-on" # 模特上身图(图片趴·不送审)
PLATFORM_KIT = "platform_kit", "Platform Kit" # 平台套图(图片趴)
FREE_CREATE = "free_create", "Free Create" # 自由创作(图片趴)
STORYBOARD = "storyboard", "Storyboard" # 分镜图(视频趴·送审)
# 送审范围(火山人像审核):视频趴含人脸 = 角色定妆照 / 三视图 / 分镜图。图片趴(上身图/套图/创作)不送审。
REVIEW_CATEGORIES = ("person", "tri_view", "storyboard")
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
asset_type = models.CharField(max_length=24, choices=Type.choices) asset_type = models.CharField(max_length=24, choices=Type.choices)
@@ -56,6 +67,45 @@ class Asset(TeamOwnedModel):
return self.name return self.name
class Model(TeamOwnedModel):
"""模特(顶级实体 = 模特库)· 团队级、可复用。
一个完整模特 = 形象图 + 三视图 + 声线(声线尾期做)。图片线(上身图)+ 视频线(角色)都能引用它。
来源:官方预制(is_official=True,打「官方模板」标签)/ 商家自建(AI 生成或真人上传)。
portrait/triview/voice 指向具体 Asset(复用资产表,不重复存文件)。
"""
class Source(models.TextChoices):
AI = "ai", "AI Generated"
UPLOAD = "upload", "Upload"
name = models.CharField(max_length=255)
is_official = models.BooleanField(default=False) # 官方模板标签
source = models.CharField(max_length=16, choices=Source.choices, default=Source.AI)
portrait_asset = models.ForeignKey(
Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_portrait"
)
triview_asset = models.ForeignKey(
Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_triview"
)
voice_asset = models.ForeignKey( # 声线,尾期接线,先留空
Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_voice"
)
description = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
class Meta:
indexes = [
# 模特库默认按团队 + 新→旧;官方模板优先靠前由序列化/查询控制
models.Index(fields=["team", "is_official", "-created_at"]),
]
ordering = ["-created_at"]
def __str__(self) -> str:
return self.name
class AssetReviewGroup(TimeStampedModel): class AssetReviewGroup(TimeStampedModel):
"""一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。""" """一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。"""
+2 -2
View File
@@ -61,7 +61,7 @@ def get_or_create_team_group(team) -> AssetReviewGroup:
def submit_asset_for_review(asset: Asset) -> None: def submit_asset_for_review(asset: Asset) -> None:
"""真人资产静默送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。""" """真人资产静默送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。"""
if not assets_client.is_enabled() or asset.category != Asset.Category.PERSON: if not assets_client.is_enabled() or asset.category not in Asset.REVIEW_CATEGORIES:
return return
url = _asset_url(asset) url = _asset_url(asset)
if not url: if not url:
@@ -112,7 +112,7 @@ def poll_team_reviews(team) -> dict:
"""轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。""" """轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。"""
out: dict[str, str] = {} out: dict[str, str] = {}
pending = Asset.objects.filter( pending = Asset.objects.filter(
team=team, category=Asset.Category.PERSON, review_status="processing", is_deleted=False team=team, category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False
) )
for asset in pending: for asset in pending:
out[str(asset.id)] = poll_asset_review(asset) out[str(asset.id)] = poll_asset_review(asset)
+69 -1
View File
@@ -1,7 +1,7 @@
from django.conf import settings from django.conf import settings
from rest_framework import serializers from rest_framework import serializers
from .models import Asset, AssetFile from .models import Asset, AssetFile, Model
from .storage import TosStorage from .storage import TosStorage
@@ -105,3 +105,71 @@ class AssetUploadSerializer(serializers.Serializer):
category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD) category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD)
description = serializers.CharField(required=False, allow_blank=True) description = serializers.CharField(required=False, allow_blank=True)
def _asset_preview(asset) -> str:
"""取资产主文件的公读直链(模特卡缩略图用)。无文件返回 """""
if asset is None:
return ""
f = asset.files.filter(is_primary=True).first() or asset.files.first()
if f is None:
return ""
if f.preview_url:
return f.preview_url
if not f.object_key or not settings.TOS.get("endpoint"):
return ""
try:
return _tos().public_url(object_key=f.object_key, bucket=f.bucket or None)
except Exception:
return ""
class ModelLibrarySerializer(serializers.ModelSerializer):
"""模特库卡片:实体字段 + 形象图/三视图缩略图直链 + 是否已挂声线。"""
portrait = serializers.SerializerMethodField()
triview = serializers.SerializerMethodField()
has_voice = serializers.SerializerMethodField()
class Meta:
model = Model
fields = [
"id",
"name",
"is_official",
"source",
"description",
"metadata",
"portrait_asset",
"triview_asset",
"voice_asset",
"portrait",
"triview",
"has_voice",
"created_at",
"updated_at",
]
read_only_fields = ["id", "is_official", "source", "created_at", "updated_at"]
def get_portrait(self, obj):
return _asset_preview(obj.portrait_asset)
def get_triview(self, obj):
return _asset_preview(obj.triview_asset)
def get_has_voice(self, obj):
return bool(obj.voice_asset_id)
def validate(self, attrs):
"""防越权:引用的形象图/三视图/声线资产必须属于当前团队。"""
from apps.common.api import get_current_team
request = self.context.get("request")
if request is None:
return attrs
team = get_current_team(request.user)
for field in ("portrait_asset", "triview_asset", "voice_asset"):
asset = attrs.get(field)
if asset is not None and asset.team_id != team.id:
raise serializers.ValidationError({field: "资产不属于当前团队"})
return attrs
+110
View File
@@ -0,0 +1,110 @@
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from .models import Asset, Model
def _mk_team(username, team_name):
user = User.objects.create_user(username=username, password="x")
team = Team.objects.create(name=team_name, owner=user)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
return user, team
class ModelLibraryApiTests(TestCase):
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
def setUp(self):
self.user, self.teamA = _mk_team("ua", "TeamA")
self.other, self.teamB = _mk_team("ub", "TeamB")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.mine = Model.objects.create(team=self.teamA, name="我的模特")
self.official = Model.objects.create(team=self.teamB, name="官方小美", is_official=True)
self.bs_other = Model.objects.create(team=self.teamB, name="别队私有") # B 的私有,A 看不到
def test_list_returns_mine_plus_official(self):
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
self.assertIn(str(self.mine.id), ids)
self.assertIn(str(self.official.id), ids) # 官方跨团队可见
self.assertNotIn(str(self.bs_other.id), ids) # 别队私有不可见
def test_tab_official_and_mine(self):
off = {m["id"] for m in self.client.get("/api/models/?tab=official").json()["results"]}
self.assertEqual(off, {str(self.official.id)})
mine = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
self.assertEqual(mine, {str(self.mine.id)})
def test_official_flag_in_payload(self):
row = next(m for m in self.client.get("/api/models/").json()["results"] if m["id"] == str(self.official.id))
self.assertTrue(row["is_official"])
def test_delete_is_soft(self):
res = self.client.delete(f"/api/models/{self.mine.id}/")
self.assertEqual(res.status_code, 204)
self.mine.refresh_from_db()
self.assertTrue(self.mine.is_deleted)
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
self.assertNotIn(str(self.mine.id), ids)
def test_official_cannot_be_deleted(self):
res = self.client.delete(f"/api/models/{self.official.id}/")
self.assertEqual(res.status_code, 403)
self.official.refresh_from_db()
self.assertFalse(self.official.is_deleted)
def test_cannot_delete_other_team_model(self):
res = self.client.delete(f"/api/models/{self.bs_other.id}/")
self.assertIn(res.status_code, (403, 404)) # 别队私有:不在可见 qs → 404,或命中守卫 → 403
self.bs_other.refresh_from_db()
self.assertFalse(self.bs_other.is_deleted)
def test_create_rejects_cross_team_asset(self):
b_asset = Asset.objects.create(team=self.teamB, name="别队图", asset_type="image", source="upload")
res = self.client.post("/api/models/", {"name": "盗用", "portrait_asset": str(b_asset.id)}, format="json")
self.assertEqual(res.status_code, 400)
def test_upload_real_person_creates_asset_and_model(self):
stored = SimpleNamespace(object_key="teams/x/models/a.png", bucket="b", content_type="image/png", size_bytes=10)
with patch("apps.assets.views.TosStorage") as Tos:
Tos.return_value.upload_fileobj.return_value = stored
f = BytesIO(b"img")
f.name = "lady.png"
res = self.client.post("/api/models/upload/", {"name": "上传妹", "file": f}, format="multipart")
self.assertEqual(res.status_code, 201)
body = res.json()
self.assertEqual(body["source"], "upload")
model = Model.objects.get(id=body["id"])
self.assertIsNotNone(model.portrait_asset)
self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT)
class ReviewScopeTests(TestCase):
"""送审范围 = 角色定妆照(person)/ 三视图(tri_view)/ 分镜图(storyboard);图片趴不送审。"""
def test_review_categories_constant(self):
self.assertEqual(Asset.REVIEW_CATEGORIES, ("person", "tri_view", "storyboard"))
def test_poll_team_reviews_only_scans_review_categories(self):
from apps.assets import review
_, team = _mk_team("uc", "TeamC")
keep = {}
for cat in ("person", "tri_view", "storyboard"):
a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
keep[cat] = str(a.id)
# 图片趴 / 场景:不应进送审队列
for cat in ("model_tryon", "model_portrait", "scene"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
out = review.poll_team_reviews(team)
self.assertEqual(set(out.keys()), set(keep.values()))
+91 -3
View File
@@ -7,16 +7,17 @@ from django.db.models import Q
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin, get_current_team from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import Asset, AssetFile from .models import Asset, AssetFile, Model
from .serializers import AssetSerializer, AssetUploadSerializer from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
from .storage import TosStorage from .storage import TosStorage
@@ -141,6 +142,93 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
return response return response
class ModelLibraryViewSet(ModelViewSet):
"""模特库(顶级实体)· 团队级 + 官方模板跨团队可见。
list 返回本团队模特 官方模板;?tab=official 只看官方?tab=mine 只看自建
真人上传走 upload action(传图 model_portrait 资产 + Model 实体)删除 = 软删(官方不可删)"""
serializer_class = ModelLibrarySerializer
pagination_class = AssetPagination
parser_classes = [JSONParser, MultiPartParser, FormParser]
search_fields = ["name", "description"]
def get_team(self):
return get_current_team(self.request.user)
def get_queryset(self):
team = self.get_team()
qs = (
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False)
.prefetch_related("portrait_asset__files", "triview_asset__files")
)
tab = self.request.query_params.get("tab")
if tab == "official":
qs = qs.filter(is_official=True)
elif tab == "mine":
qs = qs.filter(team=team, is_official=False)
if self.request.query_params.get("q"):
q = self.request.query_params["q"]
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
# 官方模板靠前,再按新→旧
return qs.order_by("-is_official", "-created_at")
def perform_create(self, serializer):
serializer.save(team=self.get_team(), created_by=self.request.user)
def perform_destroy(self, instance):
if instance.is_official:
raise PermissionDenied("官方模特不可删除")
if instance.team_id != self.get_team().id:
raise PermissionDenied("无权删除其他团队的模特")
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
def upload(self, request):
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
upload = request.FILES.get("file")
if upload is None:
raise ValidationError({"file": "请上传一张模特形象图"})
team = self.get_team()
name = (request.data.get("name") or Path(upload.name).stem or "模特")[:255]
asset_id = uuid.uuid4()
suffix = Path(upload.name).suffix.lower() or ".png"
object_key = f"teams/{team.id}/models/{asset_id}{suffix}"
with transaction.atomic():
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "image/png",
)
portrait = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=f"{name}·形象图",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.MODEL_PORTRAIT,
metadata={"kind": "model", "view": "frontal", "source": "upload"},
)
AssetFile.objects.create(
asset=portrait,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
model = Model.objects.create(
team=team,
created_by=request.user,
name=name,
source=Model.Source.UPLOAD,
portrait_asset=portrait,
metadata={"source": "upload"},
)
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
class AssetUploadView(APIView): class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser] parser_classes = [MultiPartParser, FormParser]
+3
View File
@@ -41,6 +41,7 @@ import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./r
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config"; import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { AdminApp } from "./routes/admin/admin-app"; import { AdminApp } from "./routes/admin/admin-app";
import { TrashPage } from "./routes/trash"; import { TrashPage } from "./routes/trash";
import { ModelsPage } from "./routes/models";
import { money } from "./routes/stage-config"; import { money } from "./routes/stage-config";
const crumbLabels: Partial<Record<Page, string>> = { const crumbLabels: Partial<Record<Page, string>> = {
@@ -743,6 +744,8 @@ export function App() {
</div> </div>
</div> </div>
); );
case "models":
return <ModelsPage />;
case "library": case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "trash": case "trash":
+17
View File
@@ -21,6 +21,7 @@ import type {
LoginSession, LoginSession,
Invitation, Invitation,
ModelConfig, ModelConfig,
ModelEntity,
Notification, Notification,
NotificationList, NotificationList,
Paginated, Paginated,
@@ -478,6 +479,22 @@ export const api = {
updateAsset(id: string, payload: { name?: string; description?: string }) { updateAsset(id: string, payload: { name?: string; description?: string }) {
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
}, },
// 模特库:列出(本团队 官方模板);tab=official 只看官方、mine 只看自建
listModels(params: { tab?: "official" | "mine"; q?: string; pageSize?: number } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
if (params.q) qs.set("q", params.q);
qs.set("page_size", String(params.pageSize ?? 200));
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
},
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
uploadModel(formData: FormData) {
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
},
// 删除模特(软删;官方模特不可删)
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
},
billingSummary() { billingSummary() {
return request<BillingSummary>("/api/billing/summary/"); return request<BillingSummary>("/api/billing/summary/");
}, },
@@ -4,6 +4,7 @@ const iconPaths: Record<string, string> = {
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>', layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>', trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>', package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
model: '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>', clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>', sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>', images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
@@ -190,6 +190,7 @@ type NavDef = { id: string; page: Page; label: string; icon: string; badge?: num
const NAV: NavDef[] = [ const NAV: NavDef[] = [
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" }, { id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
{ id: "products", page: "products", label: "商品库", icon: "package" }, { id: "products", page: "products", label: "商品库", icon: "package" },
{ id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" }, { id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" }, { id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "library", page: "library", label: "资产库", icon: "library" }, { id: "library", page: "library", label: "资产库", icon: "library" },
@@ -204,6 +205,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
products: "products", products: "products",
productDetail: "products", productDetail: "products",
productCreateUpload: "products", productCreateUpload: "products",
models: "models",
projects: "projects", projects: "projects",
projectWizard: "projects", projectWizard: "projects",
pipeline: "projects", pipeline: "projects",
+98
View File
@@ -0,0 +1,98 @@
/* 模特库 · 顶级实体(形象图 + 三视图)· 仅用 design-restraint token,不写裸 hex */
.models-empty { min-height: 240px; flex-direction: column; gap: 8px; }
.models-empty-hint { font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.model-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
transition: border-color .15s;
}
.model-card:hover { border-color: var(--border-loud); }
/* 形象图(9:16 氛围正面图)· 主视觉 */
.model-portrait {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
border-radius: 0;
}
/* 官方模板标签:复用 .pill.info(单橙锚点),定位左上 */
.model-official {
position: absolute;
top: 8px;
left: 8px;
pointer-events: none;
}
/* 删除按钮:默认半隐,hover 卡片才显;crimson 危险态 */
.model-del-btn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--r-sm);
background: var(--black-alpha-48);
color: var(--accent-white);
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity .15s, background .15s;
}
.model-card:hover .model-del-btn { opacity: 1; }
.model-del-btn:hover { background: var(--accent-crimson); }
.model-confirm {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--black-alpha-56);
}
.model-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.model-del:hover { filter: brightness(1.06); }
.model-body { padding: 10px 12px 12px; }
.model-name {
font-size: 13.5px;
font-weight: 500;
color: var(--accent-black);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 三视图(16:9 白底)· 成套证明 */
.model-triview {
width: 100%;
aspect-ratio: 16 / 9;
margin-top: 8px;
border-radius: var(--r-sm);
}
.model-triview .ph-frame { font-size: 11px; }
.model-tags {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 11.5px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import "../models-page.css";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
type Tab = "all" | "official" | "mine";
const TABS: { k: Tab; label: string }[] = [
{ k: "all", label: "全部" },
{ k: "official", label: "官方模板" },
{ k: "mine", label: "我的模特" }
];
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage() {
const [tab, setTab] = useState<Tab>("all");
const [items, setItems] = useState<ModelEntity[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
api
.listModels({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setItems(r.results); })
.catch(() => { if (alive) setItems([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [tab]);
async function onPick(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
form.append("name", file.name.replace(/\.[^.]+$/, ""));
const created = await api.uploadModel(form);
setItems((list) => (tab === "official" ? list : [created, ...list]));
} finally {
setUploading(false);
}
}
async function remove(id: string) {
try {
await api.deleteModel(id);
setItems((list) => list.filter((m) => m.id !== id));
} finally {
setConfirmId(null);
}
}
return (
<div className="models-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 团队级可复用 · 形象图 + 三视图</span>
<span>·</span>
<span></span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? "上传中…" : "真人上传"}
</button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 200 }}><span className="ph-frame">// 加载中…</span></div>
) : items.length === 0 ? (
<div className="placeholder models-empty">
<span className="ph-frame">// 还没有模特</span>
<span className="models-empty-hint mono">,/</span>
</div>
) : (
<div className="models-grid">
{items.map((m) => (
<div className="model-card" key={m.id}>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
>
{!m.portrait && <span className="ph-frame"></span>}
{m.is_official && <span className="pill info model-official"></span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm">
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}></button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}></button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={() => setConfirmId(m.id)}>×</button>
))}
</div>
<div className="model-body">
<div className="model-name" title={m.name}>{m.name}</div>
<div
className={`placeholder model-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
>
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
</div>
<div className="model-tags mono">
<span>{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
<span>·</span>
<span>{m.triview ? "三视图 ✓" : "三视图 —"}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+7
View File
@@ -7,6 +7,7 @@ import {
MessageSquare, MessageSquare,
Package, Package,
Settings, Settings,
UserRound,
Users, Users,
Wallet, Wallet,
WandSparkles WandSparkles
@@ -17,6 +18,7 @@ export type Page =
| "products" | "products"
| "productDetail" | "productDetail"
| "productCreateUpload" | "productCreateUpload"
| "models"
| "projects" | "projects"
| "projectWizard" | "projectWizard"
| "pipeline" | "pipeline"
@@ -57,6 +59,7 @@ export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: st
export const mainNav: NavItem[] = [ export const mainNav: NavItem[] = [
{ page: "dashboard", label: "工作台", icon: Home }, { page: "dashboard", label: "工作台", icon: Home },
{ page: "products", label: "商品库", icon: Package }, { page: "products", label: "商品库", icon: Package },
{ page: "models", label: "模特库", icon: UserRound },
{ page: "projects", label: "视频项目", icon: FolderKanban }, { page: "projects", label: "视频项目", icon: FolderKanban },
{ page: "pipeline", label: "生产管线", icon: WandSparkles }, { page: "pipeline", label: "生产管线", icon: WandSparkles },
{ page: "library", label: "资产库", icon: Library }, { page: "library", label: "资产库", icon: Library },
@@ -76,6 +79,7 @@ export const routeLabels: Record<Page, string> = {
products: "商品库", products: "商品库",
productDetail: "商品详情", productDetail: "商品详情",
productCreateUpload: "上传创建商品", productCreateUpload: "上传创建商品",
models: "模特库",
projects: "视频项目", projects: "视频项目",
projectWizard: "新建视频项目", projectWizard: "新建视频项目",
pipeline: "生产管线", pipeline: "生产管线",
@@ -129,6 +133,7 @@ export function resolveRoute(): ResolvedRoute {
if (path.startsWith("/products/")) { if (path.startsWith("/products/")) {
return { page: "productDetail", authMode: "login", productId: decodeURIComponent(path.slice("/products/".length)), hash }; return { page: "productDetail", authMode: "login", productId: decodeURIComponent(path.slice("/products/".length)), hash };
} }
if (path === "/models") return { page: "models", authMode: "login", hash };
if (path === "/projects") return { page: "projects", authMode: "login", hash }; if (path === "/projects") return { page: "projects", authMode: "login", hash };
if (path === "/projects/new") return { page: "projectWizard", authMode: "login", hash }; if (path === "/projects/new") return { page: "projectWizard", authMode: "login", hash };
if (path === "/pipeline") { if (path === "/pipeline") {
@@ -163,6 +168,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/products/new"; return "/products/new";
case "productDetail": case "productDetail":
return options.productId ? `/products/${encodeURIComponent(options.productId)}` : "/products"; return options.productId ? `/products/${encodeURIComponent(options.productId)}` : "/products";
case "models":
return "/models";
case "projects": case "projects":
return "/projects"; return "/projects";
case "projectWizard": case "projectWizard":
+18
View File
@@ -230,6 +230,24 @@ export type Asset = {
updated_at: string; updated_at: string;
}; };
// 模特库实体(顶级,团队级可复用)= 形象图 + 三视图 + 声线(声线尾期)。官方预制打 is_official 标签。
export type ModelEntity = {
id: string;
name: string;
is_official: boolean;
source: "ai" | "upload";
description: string;
metadata?: Record<string, unknown>;
portrait_asset: string | null;
triview_asset: string | null;
voice_asset: string | null;
portrait: string; // 形象图缩略图直链
triview: string; // 三视图缩略图直链
has_voice: boolean;
created_at: string;
updated_at: string;
};
export type ProjectStage = { export type ProjectStage = {
id: string; id: string;
stage: string; stage: string;
+54
View File
@@ -0,0 +1,54 @@
// 期1 模特库走查:登录 → /models → 断言卡片/官方标签/tab,逐态截图,抓 console/pageerror。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://localhost:5188";
const OUT = path.resolve("../../../_qa_shots/models-p1");
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const r = { consoleErrors: [], steps: {} };
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("PAGEERR:" + e.message));
// 登录
await p.goto(BASE + "/login", { waitUntil: "load" });
await p.waitForTimeout(400);
await p.fill("#auth-username", "airshelf");
await p.fill("#auth-pwd", "Restraint2026");
await p.click("button.btn-cta");
await p.waitForFunction(() => !location.pathname.startsWith("/login"), { timeout: 12000 });
// 左菜单是否有「模特库」入口
r.steps.navHasModels = await p.locator("nav a, aside a, .sidebar a, button").filter({ hasText: "模特库" }).count();
// 进模特库
await p.goto(BASE + "/models", { waitUntil: "load" });
await p.waitForTimeout(2800);
r.steps.h1 = await p.locator("h1").first().innerText().catch(() => "");
r.steps.tabCount = await p.locator(".tabs .tab").count();
r.steps.cardCountAll = await p.locator(".model-card").count();
r.steps.officialPill = await p.locator(".model-official").count();
r.steps.triviewThumbs = await p.locator(".model-triview").count();
r.steps.uploadBtn = await p.locator(".actions button", { hasText: /上传/ }).count();
await p.screenshot({ path: path.join(OUT, "models-all.png"), fullPage: true });
// 官方模板 tab
await p.locator(".tabs .tab", { hasText: "官方模板" }).click();
await p.waitForTimeout(2200);
r.steps.cardCountOfficial = await p.locator(".model-card").count();
r.steps.officialPillInTab = await p.locator(".model-official").count();
await p.screenshot({ path: path.join(OUT, "models-official.png"), fullPage: true });
// 我的模特 tab
await p.locator(".tabs .tab", { hasText: "我的模特" }).click();
await p.waitForTimeout(2200);
r.steps.cardCountMine = await p.locator(".model-card").count();
await p.screenshot({ path: path.join(OUT, "models-mine.png"), fullPage: true });
await browser.close();
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));
+23 -1
View File
@@ -133,7 +133,29 @@
--- ---
# ⛔ Wave 4(后端分类改造)— 待你拍 2 个板才动 # ★★ 资产模型重定义(2026-06-20 用户盘点,W4 以此为准 —— 已不止是"改分类")
这不是简单给 category 改名,是把「模特」从一张图升级成**可复用的实体/库**。三大块:
**① 模特(顶级独立类 = 模特库)**
- 一个完整模特 = **形象图 + 三视图 + 声线**(三件套合成一个模特)
- 模特库**独立成块**(像商品库),**图片线 + 视频线都能调**(=演员库);商家**真人模特可上传**入库
- 现状问题:模特库现在埋在 model-photo.html 的深层嵌套弹窗里(actor-library),UI 当初没想清楚 → 要提到顶级
**② 图片趴(3 类,均为「组」)**
- **模特上身图**(组):模特+商品宣传图,一组多张
- **平台套图**(组):电商头图/轮播主图,各平台尺寸不同,一组 N 张(平台规则后续加)
- **自由创作**:杂图都归这
**③ 视频趴**
- **模特图**:关联模特库 → 引用库中某模特的脸 → 生成符合本脚本剧情的演员**定妆照** → 再出三视图;也可直接 AI 生成角色
- **分镜图**(组):每个视频任务脚本 = 一组分镜图 + 一组视频素材
**结构含义(待规划):** 模特要从「项目内的 person 基础资产」升级为「团队级、跨流程复用的模特实体」;上身图/套图/分镜/视频都是「组」;视频流程的模特图引用模特库。→ 这是**多阶段功能**,不是一次 relabel。审核范围不变(视频趴人脸:模特定妆照/三视图/分镜送审)。
---
# ⛔ Wave 4(原"后端分类改造")— 已升级为「资产模型重定义」,需先出设计稿
摸完代码发现:**火山人像内容审核(P0 法务红线)绑死在 `category=person`**(assets/review.py:64、adminpanel 审核队列)。现在三视图算 person → **会送审**。若按新分类把三视图/模特上身图从 person 拆走,它们就**不再过内容审核**。这是合规决策,不能我自己定。 摸完代码发现:**火山人像内容审核(P0 法务红线)绑死在 `category=person`**(assets/review.py:64、adminpanel 审核队列)。现在三视图算 person → **会送审**。若按新分类把三视图/模特上身图从 person 拆走,它们就**不再过内容审核**。这是合规决策,不能我自己定。
@@ -0,0 +1,116 @@
# 模特库 + 资产模型重构 · 设计稿
> 2026-06-20 · 与用户对齐后产出 · **先拍这版图纸,再分 3 期施工**
> 现状全是测试数据、平台未上线 → 数据可随意动(归类 / 删错图)。
---
## 0. 一句话目标
把「模特」从"一张图"升级成**团队级、可复用的实体(模特库)**;图片生成线 + 视频项目线都能调它;资产按"图片趴 / 视频趴"两大块清晰分类。
---
## 1. 资产模型(实体定义)
### ① 模特(顶级实体 = 模特库)
一个完整模特 = **形象图 + 三视图 + 声线**(声线最后做)。
- **团队级、可复用**,不绑某个项目;图片线 + 视频线都能引用。
- 来源:**官方预制**(打「官方模板」标签)/ **商家自建**(AI 生成或上传真人)。
- 模特库 = **左侧顶级菜单**(像商品库),不再埋在弹窗里。
### ② 图片趴(3 类,均为「组」)
| 类 | 说明 | 形态 |
|---|---|---|
| 模特上身图 | 模特 + 商品 宣传图(引用模特库的模特) | 一组多张 |
| 平台套图 | 电商头图/轮播主图,各平台尺寸不同 | 一组 N 张(平台规则后续加) |
| 自由创作 | 杂图都归这 | 单张/多张 |
### ③ 视频趴
| 类 | 说明 | 形态 |
|---|---|---|
| 角色(原「人物」改名) | 见下方"角色链路" | 项目内,引用模特库 |
| 分镜图 | 每个视频任务脚本 = 一组分镜图 + 一组视频素材 | 组 |
**角色链路(视频趴):**
- **A 引用模特库**:选库里某模特当参考图 + 叠加**符合剧情的服装样貌描述** → 生成该模特的**定妆照** → 再出三视图。
- **B 直接新生成**:不用库里的,新生成一个 → **自动入模特库**(要删去模特库删)。
- → "角色"卡需小改以承载这两条来法。
---
## 2. 后端模型设计(提案,期 1 细化)
**新增 `Model`(模特)实体 · 团队级:**
```
Model: team, name, is_official(官方模板), source(ai/upload),
portrait_asset(形象图 FK), triview_asset(三视图 FK),
voice_asset(声线 FK, 可空·后做), metadata, created_by
```
**`Asset.Category` 枚举一次加全(逐期接线):**
- model_portrait(模特形象图)· tri_view(三视图)· voice(声线占位)
- model_tryon(模特上身图)· platform_kit(平台套图)· free_create(自由创作)
- storyboard(分镜图)
- 保留:product_image / scene / video_clip / final_video / upload / uncategorized
**「组」落库:** 复用/扩展现有 BaseAssetGroup 或加 group_id+task 关联(期 2/3 定);上身图组/套图组/分镜组/视频组同源思路。
**审核范围(不变,已拍板):** 送审 = 视频趴人脸 = **{模特定妆照(person), tri_view(三视图), storyboard(分镜图)}**;图片趴(上身图/套图/创作)**不送审**。`review.py` + adminpanel 队列过滤 `person → person+tri_view+storyboard`
---
## 3. 页面 / 导航
- **新增顶级「模特库」**(左菜单):官方模板 tab / 我的模特 tab / 真人上传入口。
- **图片生成页**:三类入口(上身图/套图/自由创作),上身图接模特库。
- **视频项目·资产趴**:"人物"→"角色",角色卡承载 A/B 两条来法。
- **深埋的 actor-library 弹窗**:本轮先不动(逻辑顺),做到对应页时再收编。
---
## ★ 施工纪律(每一块/每个 Phase 都严格走这一圈,缺一不可)
> 这是硬流程,不是口号。任何一块没走完整圈,不算完、不 commit、不进下一块。
1. **写(开发)** — 实现这一块;后端先于前端;独立子项可派 sub-agent 并行(worktree 隔离防冲突)。
2. **审(自审)** — 回头**批判性**读自己刚写的 diff(或派 sub-agent 当审查者):逻辑 / 边界(空值·0·越权·并发)/ 安全(密钥不回传·权限不漏)/ **回归风险** / 有没有跑题。发现问题 → 修 → 再审,**审干净才往下**。
3. **改(修)** — 上面审出来的、下面测出来的,全修掉。
4. **测(三层全绿,缺一不可)**:
- **后端单测**:`DB_ENGINE=sqlite python manage.py test apps.<相关app>`;每个新端点 ≥ 正常 + 权限拒绝 + 边界。
- **无头浏览器 e2e**:Playwright 真点击走完整流程 + locator 断言 + **抓 console/pageerror 必须为 0**;按需起 worker 真跑异步(出图/审核)。
- **类型/构建**:`npx tsc --noEmit && npm run build` 全绿。
5. **无头 + 截图核对** — 关键画面**截图**,逐张对照设计稿 / 预期,**视觉达标**才算过;不达标 → 回第 3 步继续迭代。
6. **直到完美验收** — 三层全绿 + 截图达标 + 该块业务链路走通,才算这块完成。
7. **全绿才本地 commit**(消息规范);写进度;**进下一块**。
8. **回归红线**:开工前记下进场既有失败基线,只保证**不新增** fail;既有链路零回归。
9. **全程本地、绝不 push**;不碰 master/main、不 `--no-verify`、不绕 hook。
— 即「**写 → 审 → 改 → 测 → 无头 → 截图 → 不达标继续迭代 → 完美验收 → commit**」,一圈不少。
## 4. 分 3 期开发(每期都走上面这一圈;不 push)
### 期 1 · 模特库地基
- 后端:`Model` 实体 + 迁移;`Asset.Category` 枚举加全;审核过滤改 person+tri_view+storyboard;后端单测。
- 数据:把**历史已成套模特(形象图+三视图)归集成 Model 实体**当测试数据;**删早期错图**理干净。
- 前端:左菜单 +「模特库」页(官方模板标签 / 我的模特 / 真人上传)。
- 验收:模特库页能看到归好的成套模特、官方标签;无头截图 + 0 console error。
### 期 2 · 图片趴三类
- 模特上身图(组,引用模特库)/ 平台套图(组)/ 自由创作 —— 落库 + 图片生成页接线。
- 前端标签表全对(上身图/套图/创作各归各类)。
- 验收:三类各生成→归类正确;截图核对。
### 期 3 · 视频趴(角色 + 分镜组)
- "人物"→"角色"改名 + 角色卡小改(承载 A 引用库 / B 新生成自动入库)。
- 分镜图成组(每脚本一组分镜 + 一组视频素材),理顺现有链路。
- 资产库"成片"→"素材";最终成片类隐藏。
- 验收:角色两条来法走通、分镜组正确;截图 + 链路零回归。
### 尾 · 声线(最后)
- 模特声线生成 + 模特实体挂声线 + 参考元素加"声音"选项。
---
## 5. 待定 / 后续
- 平台套图各平台尺寸规则(逐平台加)。
- 角色卡 UI 细节(做到期 3 再逐稿对)。
- 声线打包方式(挂在模特实体下)。
- 这些页用户还要逐页再过一遍。