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:
co-authored by
Claude Opus 4.8
parent
1c9143ce61
commit
44e90233ff
@@ -10,6 +10,7 @@ urlpatterns = [
|
||||
path("api/auth/", include("apps.accounts.urls")),
|
||||
path("api/products/", include("apps.products.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/billing/", include("apps.billing.urls")),
|
||||
path("api/ai/", include("apps.ai.urls")),
|
||||
|
||||
@@ -289,9 +289,9 @@ _REVIEW_STATUSES = {"", "processing", "active", "failed"}
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_asset_reviews(request):
|
||||
"""跨团队真人(person)资产审核队列。?review_status=none|processing|active|failed 过滤(none=未送审)。"""
|
||||
"""跨团队送审资产审核队列(角色定妆照/三视图/分镜图)。?review_status=none|processing|active|failed 过滤(none=未送审)。"""
|
||||
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")
|
||||
.prefetch_related("files")
|
||||
.order_by("-created_at")
|
||||
@@ -314,7 +314,7 @@ def admin_asset_reviews(request):
|
||||
def admin_asset_reviews_submit(request):
|
||||
"""批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。"""
|
||||
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:
|
||||
submit_asset_for_review(asset)
|
||||
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):
|
||||
"""轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。"""
|
||||
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:
|
||||
qs = qs.filter(id__in=ids)
|
||||
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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -18,13 +18,24 @@ class Asset(TeamOwnedModel):
|
||||
SYSTEM = "system", "System"
|
||||
|
||||
class Category(models.TextChoices):
|
||||
PERSON = "person", "Person"
|
||||
PERSON = "person", "Person" # 视频趴·角色定妆照(送审)
|
||||
SCENE = "scene", "Scene"
|
||||
PRODUCT_IMAGE = "product_image", "Product Image"
|
||||
VIDEO_CLIP = "video_clip", "Video Clip"
|
||||
FINAL_VIDEO = "final_video", "Final Video"
|
||||
UPLOAD = "upload", "Upload"
|
||||
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)
|
||||
asset_type = models.CharField(max_length=24, choices=Type.choices)
|
||||
@@ -56,6 +67,45 @@ class Asset(TeamOwnedModel):
|
||||
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):
|
||||
"""一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。"""
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ def get_or_create_team_group(team) -> AssetReviewGroup:
|
||||
|
||||
def submit_asset_for_review(asset: Asset) -> None:
|
||||
"""真人资产静默送审:建组(若无)→ 传素材 → 标 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
|
||||
url = _asset_url(asset)
|
||||
if not url:
|
||||
@@ -112,7 +112,7 @@ def poll_team_reviews(team) -> dict:
|
||||
"""轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。"""
|
||||
out: dict[str, str] = {}
|
||||
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:
|
||||
out[str(asset.id)] = poll_asset_review(asset)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Asset, AssetFile
|
||||
from .models import Asset, AssetFile, Model
|
||||
from .storage import TosStorage
|
||||
|
||||
|
||||
@@ -105,3 +105,71 @@ class AssetUploadSerializer(serializers.Serializer):
|
||||
category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD)
|
||||
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
|
||||
|
||||
|
||||
@@ -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()))
|
||||
@@ -7,16 +7,17 @@ from django.db.models import Q
|
||||
from django.http import StreamingHttpResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
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.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
|
||||
from .models import Asset, AssetFile
|
||||
from .serializers import AssetSerializer, AssetUploadSerializer
|
||||
from .models import Asset, AssetFile, Model
|
||||
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
||||
from .storage import TosStorage
|
||||
|
||||
|
||||
@@ -141,6 +142,93 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
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):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./r
|
||||
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
|
||||
import { AdminApp } from "./routes/admin/admin-app";
|
||||
import { TrashPage } from "./routes/trash";
|
||||
import { ModelsPage } from "./routes/models";
|
||||
import { money } from "./routes/stage-config";
|
||||
|
||||
const crumbLabels: Partial<Record<Page, string>> = {
|
||||
@@ -743,6 +744,8 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "models":
|
||||
return <ModelsPage />;
|
||||
case "library":
|
||||
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
|
||||
case "trash":
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
LoginSession,
|
||||
Invitation,
|
||||
ModelConfig,
|
||||
ModelEntity,
|
||||
Notification,
|
||||
NotificationList,
|
||||
Paginated,
|
||||
@@ -478,6 +479,22 @@ export const api = {
|
||||
updateAsset(id: string, payload: { name?: string; description?: string }) {
|
||||
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() {
|
||||
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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
|
||||
@@ -190,6 +190,7 @@ type NavDef = { id: string; page: Page; label: string; icon: string; badge?: num
|
||||
const NAV: NavDef[] = [
|
||||
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
|
||||
{ id: "products", page: "products", label: "商品库", icon: "package" },
|
||||
{ id: "models", page: "models", label: "模特库", icon: "model" },
|
||||
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
|
||||
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
|
||||
{ id: "library", page: "library", label: "资产库", icon: "library" },
|
||||
@@ -204,6 +205,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
products: "products",
|
||||
productDetail: "products",
|
||||
productCreateUpload: "products",
|
||||
models: "models",
|
||||
projects: "projects",
|
||||
projectWizard: "projects",
|
||||
pipeline: "projects",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +7,7 @@ import {
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
UserRound,
|
||||
Users,
|
||||
Wallet,
|
||||
WandSparkles
|
||||
@@ -17,6 +18,7 @@ export type Page =
|
||||
| "products"
|
||||
| "productDetail"
|
||||
| "productCreateUpload"
|
||||
| "models"
|
||||
| "projects"
|
||||
| "projectWizard"
|
||||
| "pipeline"
|
||||
@@ -57,6 +59,7 @@ export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: st
|
||||
export const mainNav: NavItem[] = [
|
||||
{ page: "dashboard", label: "工作台", icon: Home },
|
||||
{ page: "products", label: "商品库", icon: Package },
|
||||
{ page: "models", label: "模特库", icon: UserRound },
|
||||
{ page: "projects", label: "视频项目", icon: FolderKanban },
|
||||
{ page: "pipeline", label: "生产管线", icon: WandSparkles },
|
||||
{ page: "library", label: "资产库", icon: Library },
|
||||
@@ -76,6 +79,7 @@ export const routeLabels: Record<Page, string> = {
|
||||
products: "商品库",
|
||||
productDetail: "商品详情",
|
||||
productCreateUpload: "上传创建商品",
|
||||
models: "模特库",
|
||||
projects: "视频项目",
|
||||
projectWizard: "新建视频项目",
|
||||
pipeline: "生产管线",
|
||||
@@ -129,6 +133,7 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path.startsWith("/products/")) {
|
||||
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/new") return { page: "projectWizard", authMode: "login", hash };
|
||||
if (path === "/pipeline") {
|
||||
@@ -163,6 +168,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
return "/products/new";
|
||||
case "productDetail":
|
||||
return options.productId ? `/products/${encodeURIComponent(options.productId)}` : "/products";
|
||||
case "models":
|
||||
return "/models";
|
||||
case "projects":
|
||||
return "/projects";
|
||||
case "projectWizard":
|
||||
|
||||
@@ -230,6 +230,24 @@ export type Asset = {
|
||||
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 = {
|
||||
id: string;
|
||||
stage: string;
|
||||
|
||||
@@ -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));
|
||||
@@ -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 拆走,它们就**不再过内容审核**。这是合规决策,不能我自己定。
|
||||
|
||||
|
||||
@@ -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 再逐稿对)。
|
||||
- 声线打包方式(挂在模特实体下)。
|
||||
- 这些页用户还要逐页再过一遍。
|
||||
Reference in New Issue
Block a user