feat: 生图模型可选(火山/gpt-image) + 工作台图「加入资产库」收纳 + 任务中心排序修复
- AI 工作室生图支持显式选模型:resolve_image_model 解析 volcano(Seedream 图生图, 无 image_edit 时走 image_generation 带参考图)/ gpt-image(image_edit 多图编辑), 未选回落系统默认;enqueue_standalone_images 透传 image_model - 资产库收纳:Asset 加 in_library 字段(迁移 0007,既有资产 db_default=True 不动); 工作台生成图默认 in_library=False,只在工作台展示,用户「加入资产库」后才进库列表; assets 视图/序列化器/library 页/types 配套 - 任务中心修复:AITaskViewSet 默认 order_by(-created_at),前端 aiTasks 取 page_size=200。 原因:列表无排序时 MySQL 按 UUID 主键乱序返回,把一批旧失败记录顶到首页, 前端又只取首页 20 条算 全部/已完成/失败 → 误显示「全部失败」(实为 421 成功/少量失败) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,28 @@ def get_default_model(capability: str) -> ModelConfig:
|
|||||||
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
|
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_image_model(key: str | None) -> "ModelConfig | None":
|
||||||
|
"""前端「生图模型选择」→ ModelConfig。用户显式选的可以是 disabled 模型(故不按 status 过滤)。
|
||||||
|
· "volcano" → 火山官方 Seedream(取最新一版)
|
||||||
|
· "gpt-image"→ gpt-image-2(优先 active provider 的那个)
|
||||||
|
· "provider:name" 或裸 name → 精确匹配
|
||||||
|
解析不到返回 None,调用方回落 get_default_model。"""
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
qs = ModelConfig.objects.select_related("provider").filter(capability=ModelConfig.Capability.IMAGE)
|
||||||
|
if key == "volcano":
|
||||||
|
# 火山 Seedream:优先版本号最高的(seedream-5 > seedream-4),按 name 倒序
|
||||||
|
vqs = qs.filter(provider__name__in=OFFICIAL_DIRECT_PROVIDERS)
|
||||||
|
return vqs.filter(name__icontains="seedream").order_by("-name").first() or vqs.order_by("-name").first()
|
||||||
|
if key in ("gpt-image", "gpt-image-2"):
|
||||||
|
return (qs.filter(name__icontains="gpt-image", provider__status="active").first()
|
||||||
|
or qs.filter(name__icontains="gpt-image").first())
|
||||||
|
if ":" in key:
|
||||||
|
pname, mname = key.split(":", 1)
|
||||||
|
return qs.filter(provider__name=pname, name=mname).first()
|
||||||
|
return qs.filter(name=key).first()
|
||||||
|
|
||||||
|
|
||||||
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
|
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
|
||||||
# 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。
|
# 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。
|
||||||
# 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。
|
# 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。
|
||||||
@@ -1976,7 +1998,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None) -> list[AITask]:
|
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None) -> list[AITask]:
|
||||||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||||||
|
|
||||||
@@ -1986,7 +2008,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
from apps.ai.tasks import generate_standalone_image_task
|
from apps.ai.tasks import generate_standalone_image_task
|
||||||
|
|
||||||
_reap_stale_standalone_image_tasks(team=team)
|
_reap_stale_standalone_image_tasks(team=team)
|
||||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
# 用户在工作室选的生图模型(火山 / gpt-image)优先;未选或解析不到则回落系统默认
|
||||||
|
model_config = resolve_image_model(image_model) or get_default_model(ModelConfig.Capability.IMAGE)
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
raise ValueError("no active image model configured")
|
raise ValueError("no active image model configured")
|
||||||
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
||||||
@@ -2060,30 +2083,35 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
# 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌)
|
# 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌)
|
||||||
product_urls = _product_reference_urls(product, limit=3) if product is not None else []
|
product_urls = _product_reference_urls(product, limit=3) if product is not None else []
|
||||||
|
|
||||||
|
# 参考图收集与 provider 无关:先把「该用哪些参考图 + 哪条提示词」定下来,再按模型能力选调用方式。
|
||||||
edit_images: list[str] = []
|
edit_images: list[str] = []
|
||||||
edit_prompt = ""
|
edit_prompt = ""
|
||||||
if mode == "model" and can_edit and product_urls:
|
if mode == "model" and product_urls:
|
||||||
# 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特)
|
# 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特)
|
||||||
edit_images = product_urls + ([model_url] if model_url else [])
|
edit_images = product_urls + ([model_url] if model_url else [])
|
||||||
edit_prompt = build_model_tryon_prompt_refs(
|
edit_prompt = build_model_tryon_prompt_refs(
|
||||||
product, has_model=bool(model_url), base_prompt=prompt,
|
product, has_model=bool(model_url), base_prompt=prompt,
|
||||||
index=index, n_product=len(product_urls),
|
index=index, n_product=len(product_urls),
|
||||||
)
|
)
|
||||||
elif mode == "cover" and can_edit and product_url:
|
elif mode == "cover" and product_url:
|
||||||
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
|
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
|
||||||
edit_images = [product_url] + ([model_url] if model_url else [])
|
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||||
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
||||||
elif bool(payload.get("reference_product")) and can_edit and product_url:
|
elif bool(payload.get("reference_product")) and product_url:
|
||||||
edit_images = [product_url]
|
edit_images = [product_url]
|
||||||
edit_prompt = build_product_triview_prompt_refs(product, "")
|
edit_prompt = build_product_triview_prompt_refs(product, "")
|
||||||
use_edit = bool(edit_images)
|
use_edit = bool(edit_images)
|
||||||
try:
|
try:
|
||||||
if use_edit:
|
if use_edit and can_edit:
|
||||||
|
# gpt-image 等支持 image_edit(多图参考编辑接口)
|
||||||
if payload.get("reference_product"):
|
if payload.get("reference_product"):
|
||||||
size = "1536x1024" # 三视图固定横向
|
size = "1536x1024" # 三视图固定横向
|
||||||
else:
|
else:
|
||||||
size = _ratio_to_image_size(str(payload.get("ratio") or "")) # 模特图按选中比例
|
size = _ratio_to_image_size(str(payload.get("ratio") or "")) # 模特图按选中比例
|
||||||
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=edit_images, size=size)
|
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=edit_images, size=size)
|
||||||
|
elif use_edit:
|
||||||
|
# 火山 Seedream 无 image_edit:走 image_generation 带 image=参考图(图生图多参考),size 用 2K
|
||||||
|
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size="2K")
|
||||||
else:
|
else:
|
||||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||||
media = provider.extract_first_media_url(response)
|
media = provider.extract_first_media_url(response)
|
||||||
@@ -2112,6 +2140,8 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
||||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
||||||
metadata=asset_meta,
|
metadata=asset_meta,
|
||||||
|
# 工作台生成的图默认不进资产库列表,只在工作台展示;用户「加入资产库」后才置 True
|
||||||
|
in_library=False,
|
||||||
)
|
)
|
||||||
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
||||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class GenerateImageView(APIView):
|
|||||||
model_id = str(request.data.get("model_id") or "").strip() or None
|
model_id = str(request.data.get("model_id") or "").strip() or None
|
||||||
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
|
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
|
||||||
ratio = str(request.data.get("ratio") or "").strip() or None
|
ratio = str(request.data.get("ratio") or "").strip() or None
|
||||||
|
image_model = str(request.data.get("image_model") or "").strip() or None
|
||||||
team = get_current_team(request.user)
|
team = get_current_team(request.user)
|
||||||
try:
|
try:
|
||||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio)
|
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model)
|
||||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
return Response(
|
return Response(
|
||||||
@@ -69,7 +70,10 @@ class GenerateImageView(APIView):
|
|||||||
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||||
# 序列化器不含 request_payload/response_payload(单条可达 3MB+ base64 图),defer 掉:
|
# 序列化器不含 request_payload/response_payload(单条可达 3MB+ base64 图),defer 掉:
|
||||||
# 否则只为序列化 14 个小字段也会把几十 MB blob 从库里拉回(远程库实测 40 条要 30s+)。
|
# 否则只为序列化 14 个小字段也会把几十 MB blob 从库里拉回(远程库实测 40 条要 30s+)。
|
||||||
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").defer("request_payload", "response_payload").all()
|
# 默认按创建时间倒序:任务中心 = 历史流水,最新的(多为成功)排最前。
|
||||||
|
# 缺省排序时 MySQL 按主键(UUID)乱序返回,会把一批旧失败记录顶到首页,
|
||||||
|
# 前端只取首页 → 误判「全部失败」。order_by 保证稳定且新任务优先。
|
||||||
|
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").defer("request_payload", "response_payload").order_by("-created_at")
|
||||||
serializer_class = AITaskSerializer
|
serializer_class = AITaskSerializer
|
||||||
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
|
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
|
||||||
ordering_fields = ["created_at", "updated_at", "completed_at"]
|
ordering_fields = ["created_at", "updated_at", "completed_at"]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-06-27 02:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("assets", "0006_alter_asset_category_model"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="asset",
|
||||||
|
name="in_library",
|
||||||
|
field=models.BooleanField(db_default=True, default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -51,6 +51,10 @@ class Asset(TeamOwnedModel):
|
|||||||
)
|
)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
is_deleted = models.BooleanField(default=False)
|
is_deleted = models.BooleanField(default=False)
|
||||||
|
# 是否进「资产库」列表:工作台生成的图(模特上身图/平台套图/自由创作)默认 False(只在工作台展示),
|
||||||
|
# 用户「加入资产库」后才置 True 出现在资产库列表;「取消加入」置回 False 即从列表移出。
|
||||||
|
# 字段默认 True 是为了让历史数据 / 上传 / 项目内资产保持原样可见(只对新生成的工作台图显式置 False)。
|
||||||
|
in_library = models.BooleanField(default=True, db_default=True)
|
||||||
# 火山人像素材库审核(仅真人 person 资产):"" = 未送审 / processing 审核中 / active 绿盾通过 / failed 红标(需改提示词重生)
|
# 火山人像素材库审核(仅真人 person 资产):"" = 未送审 / processing 审核中 / active 绿盾通过 / failed 红标(需改提示词重生)
|
||||||
review_status = models.CharField(max_length=16, blank=True, db_default="")
|
review_status = models.CharField(max_length=16, blank=True, db_default="")
|
||||||
review_remote_id = models.CharField(max_length=128, blank=True, db_default="")
|
review_remote_id = models.CharField(max_length=128, blank=True, db_default="")
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class AssetSerializer(serializers.ModelSerializer):
|
|||||||
"description",
|
"description",
|
||||||
"metadata",
|
"metadata",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
|
"in_library",
|
||||||
"origin_task",
|
"origin_task",
|
||||||
"product",
|
"product",
|
||||||
"files",
|
"files",
|
||||||
@@ -95,7 +96,8 @@ class AssetSerializer(serializers.ModelSerializer):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "review_status", "review_error", "created_at", "updated_at"]
|
# in_library 只读:增删资产库走专用 set-library 接口,不允许普通 PATCH 直接改
|
||||||
|
read_only_fields = ["id", "in_library", "review_status", "review_error", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
class AssetUploadSerializer(serializers.Serializer):
|
class AssetUploadSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -141,6 +141,39 @@ class VideoPacksTests(TestCase):
|
|||||||
self.assertEqual(len(data[0]["clips"]), 1)
|
self.assertEqual(len(data[0]["clips"]), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetBatchesTests(TestCase):
|
||||||
|
"""图片趴(模特上身图)按生成批次成组:同 metadata.batch_id 的多张图 = 一批;
|
||||||
|
无 batch_id 的旧图各自成单图批。summary 也按批次计数。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user, self.team = _mk_team("ubt", "TeamBT")
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
# 一批 3 张(同 batch_id)
|
||||||
|
for i in range(3):
|
||||||
|
Asset.objects.create(
|
||||||
|
team=self.team, name=f"AI 生成 · 模特上身图 · {i + 1}", asset_type="image",
|
||||||
|
source="ai_generated", category=Asset.Category.MODEL_TRYON, metadata={"batch_id": "B1"},
|
||||||
|
)
|
||||||
|
# 一张旧图(无 batch_id)→ 自己成一批
|
||||||
|
Asset.objects.create(
|
||||||
|
team=self.team, name="AI 生成 · 模特上身图 · 1", asset_type="image",
|
||||||
|
source="ai_generated", category=Asset.Category.MODEL_TRYON,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_batches_grouped(self):
|
||||||
|
data = self.client.get("/api/assets/batches/?tab=tryon&page_size=10").json()
|
||||||
|
self.assertEqual(data["count"], 2) # 一批 3 张 + 一张单图批
|
||||||
|
by_count = sorted(b["count"] for b in data["results"])
|
||||||
|
self.assertEqual(by_count, [1, 3])
|
||||||
|
big = next(b for b in data["results"] if b["count"] == 3)
|
||||||
|
self.assertEqual(big["name"], "AI 生成 · 模特上身图") # 去掉「· 序号」
|
||||||
|
self.assertEqual(len(big["items"]), 3)
|
||||||
|
|
||||||
|
def test_summary_counts_batches(self):
|
||||||
|
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 2)
|
||||||
|
|
||||||
|
|
||||||
class SubmitReviewTests(TestCase):
|
class SubmitReviewTests(TestCase):
|
||||||
"""手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。"""
|
"""手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -61,6 +62,27 @@ def _tab_q(tab: str) -> Q:
|
|||||||
return Q()
|
return Q()
|
||||||
|
|
||||||
|
|
||||||
|
# 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。
|
||||||
|
BATCH_TABS = {"tryon", "kits", "creations"}
|
||||||
|
|
||||||
|
# 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。
|
||||||
|
_BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_key(batch_id, origin_task_id, asset_id) -> str:
|
||||||
|
"""资产归批:优先 metadata.batch_id;旧图无 batch_id 时回落 origin_task,再无则自身成单图批。"""
|
||||||
|
if batch_id:
|
||||||
|
return f"batch:{batch_id}"
|
||||||
|
if origin_task_id:
|
||||||
|
return f"task:{origin_task_id}"
|
||||||
|
return f"asset:{asset_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_name(name: str) -> str:
|
||||||
|
cleaned = _BATCH_NAME_RE.sub("", name or "").strip()
|
||||||
|
return cleaned or (name or "")
|
||||||
|
|
||||||
|
|
||||||
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||||
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。
|
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。
|
||||||
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
|
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
|
||||||
@@ -82,6 +104,10 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering。
|
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering。
|
||||||
默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。"""
|
默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。"""
|
||||||
qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库
|
qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库
|
||||||
|
# 资产库列表/批次只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
|
||||||
|
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
|
||||||
|
if self.action in ("list", "batches"):
|
||||||
|
qs = qs.filter(in_library=True)
|
||||||
p = self.request.query_params
|
p = self.request.query_params
|
||||||
if p.get("tab"):
|
if p.get("tab"):
|
||||||
qs = qs.filter(_tab_q(p["tab"]))
|
qs = qs.filter(_tab_q(p["tab"]))
|
||||||
@@ -110,11 +136,63 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
@action(detail=False, methods=["get"])
|
||||||
def summary(self, request):
|
def summary(self, request):
|
||||||
"""资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。"""
|
"""资产库 tab 计数,供 tab 徽标——不必取全量。
|
||||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
|
图片趴三类(模特上身图/平台套图/自由创作)按「生成批次」计数(与卡片成组展示一致);
|
||||||
# 资产库成品化:对外只数成品三类 + 其他(角色/场景/商品图各回各库,视频成品走 video-packs)
|
其他(我的上传 + 兜底)仍按资产张数计。"""
|
||||||
tabs = ["tryon", "kits", "creations", "others"]
|
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||||
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
|
out = {}
|
||||||
|
for t in BATCH_TABS: # 图片趴:数批次
|
||||||
|
seen = set()
|
||||||
|
rows = base.filter(_tab_q(t)).values("id", "origin_task_id", "metadata")
|
||||||
|
for row in rows:
|
||||||
|
seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"]))
|
||||||
|
out[t] = len(seen)
|
||||||
|
out["others"] = base.filter(_tab_q("others")).count()
|
||||||
|
return Response(out)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def batches(self, request):
|
||||||
|
"""图片趴(模特上身图/平台套图/自由创作)按生成批次成组:同一次提交的 N 张图
|
||||||
|
共享 metadata.batch_id = 一批;无 batch_id 的旧图回落 origin_task / 自身成单图批。
|
||||||
|
复用 get_queryset 的 tab/source/q/m_* 过滤 + ordering,按「批」分页返回,每批含封面 + 整批资产。"""
|
||||||
|
from apps.assets.serializers import _asset_preview
|
||||||
|
|
||||||
|
qs = self.get_queryset() # team scope + tab/source/q/meta 过滤 + ordering + defer
|
||||||
|
groups: dict[str, list] = {}
|
||||||
|
order: list[str] = [] # 保留资产 ordering 决定的批次先后(尊重最近/最早排序)
|
||||||
|
for asset in qs:
|
||||||
|
key = _batch_key((asset.metadata or {}).get("batch_id"), asset.origin_task_id, asset.id)
|
||||||
|
bucket = groups.get(key)
|
||||||
|
if bucket is None:
|
||||||
|
groups[key] = bucket = []
|
||||||
|
order.append(key)
|
||||||
|
bucket.append(asset)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = max(1, int(request.query_params.get("page", 1)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
page = 1
|
||||||
|
try:
|
||||||
|
page_size = min(200, max(1, int(request.query_params.get("page_size", 20))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
page_size = 20
|
||||||
|
|
||||||
|
total = len(order)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
ctx = self.get_serializer_context()
|
||||||
|
results = []
|
||||||
|
for key in order[start:start + page_size]:
|
||||||
|
assets = groups[key]
|
||||||
|
first = assets[0]
|
||||||
|
results.append({
|
||||||
|
"batch_id": key,
|
||||||
|
"name": _batch_name(first.name),
|
||||||
|
"count": len(assets),
|
||||||
|
"cover": _asset_preview(first),
|
||||||
|
"created_at": first.created_at,
|
||||||
|
"items": AssetSerializer(assets, many=True, context=ctx).data,
|
||||||
|
})
|
||||||
|
return Response({"count": total, "results": results})
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="submit-review")
|
@action(detail=True, methods=["post"], url_path="submit-review")
|
||||||
def submit_review(self, request, pk=None):
|
def submit_review(self, request, pk=None):
|
||||||
@@ -136,6 +214,17 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
)
|
)
|
||||||
return Response({"review_status": asset.review_status, "review_error": asset.review_error or ""})
|
return Response({"review_status": asset.review_status, "review_error": asset.review_error or ""})
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="set-library")
|
||||||
|
def set_library(self, request, pk=None):
|
||||||
|
"""单图「加入资产库 / 取消加入」:置 in_library。加入 → 出现在资产库列表;取消 → 从列表移出。
|
||||||
|
注:资产在生成时已落库并扣费,此口只控制是否进「资产库」展示列表,不增删真实资产、不二次扣费。"""
|
||||||
|
asset = self.get_object() # team-scoped;此 action 不过滤 in_library,故未加入的图也能取到
|
||||||
|
in_lib = bool(request.data.get("in_library", True))
|
||||||
|
if asset.in_library != in_lib:
|
||||||
|
asset.in_library = in_lib
|
||||||
|
asset.save(update_fields=["in_library"])
|
||||||
|
return Response({"id": str(asset.id), "in_library": asset.in_library})
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], url_path="video-packs")
|
@action(detail=False, methods=["get"], url_path="video-packs")
|
||||||
def video_packs(self, request):
|
def video_packs(self, request):
|
||||||
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
|
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
|
||||||
@@ -180,7 +269,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
def facets(self, request):
|
def facets(self, request):
|
||||||
"""某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。
|
"""某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。
|
||||||
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
|
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
|
||||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
|
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||||
if request.query_params.get("tab"):
|
if request.query_params.get("tab"):
|
||||||
base = base.filter(_tab_q(request.query_params["tab"]))
|
base = base.filter(_tab_q(request.query_params["tab"]))
|
||||||
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)
|
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""内衣·真人上身:换火山 Seedream 生图模型,看会不会被审核拦(对照 gpt-image-2 的 sexual 拦截)。
|
||||||
|
跑法: .venv/bin/python tryon_volcano_test.py
|
||||||
|
产物: /Users/maidong/Desktop/zyc/volcano_test
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from apps.products.models import Product # noqa: E402
|
||||||
|
from apps.assets.models import Asset # noqa: E402
|
||||||
|
from apps.ai.models import ModelConfig # noqa: E402
|
||||||
|
from apps.ai.services import ( # noqa: E402
|
||||||
|
get_image_provider, build_model_tryon_prompt_refs, _product_reference_urls, _asset_preview_url,
|
||||||
|
)
|
||||||
|
from apps.ai.providers.volcano import VolcanoArkProvider # noqa: E402
|
||||||
|
|
||||||
|
PRODUCT_ID = "42078cdc-8714-4fc7-be5c-6aa42b7fc995" # 棉居莫代尔无痕内衣
|
||||||
|
MODEL_ID = "ad362810-cf3e-49c0-9426-8142a74b75b2" # 真人模特
|
||||||
|
OUT_DIR = "/Users/maidong/Desktop/zyc/volcano_test"
|
||||||
|
# 试两个版本(从新到旧),哪个可用用哪个
|
||||||
|
VOLCANO_MODELS = ["doubao-seedream-5-0-260128", "doubao-seedream-4-5-251128"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
p = Product.objects.get(id=PRODUCT_ID)
|
||||||
|
product_urls = _product_reference_urls(p, limit=3)
|
||||||
|
model_url = _asset_preview_url(Asset.objects.get(id=MODEL_ID))
|
||||||
|
images = product_urls + [model_url]
|
||||||
|
prompt = build_model_tryon_prompt_refs(p, has_model=True, base_prompt="", index=0, n_product=len(product_urls))
|
||||||
|
print(f"商品: {p.title} | 类目: {p.category} | 商品图数: {len(product_urls)} + 模特图")
|
||||||
|
print(f"提示词:\n{prompt}\n")
|
||||||
|
|
||||||
|
for mname in VOLCANO_MODELS:
|
||||||
|
mc = ModelConfig.objects.filter(capability="image", name=mname).first()
|
||||||
|
if mc is None:
|
||||||
|
print(f"⚠️ 库里没有模型配置 {mname},跳过")
|
||||||
|
continue
|
||||||
|
provider = get_image_provider(mc)
|
||||||
|
print(f"\n━━━ 火山 {mc.provider.name}:{mname} ━━━")
|
||||||
|
try:
|
||||||
|
# Seedream 多图参考走 image 参数(可传 URL 列表);size 用 2K
|
||||||
|
resp = provider.image_generation(model=mname, prompt=prompt, image=images, size="2K")
|
||||||
|
media = provider.extract_first_media_url(resp)
|
||||||
|
fileobj, ct = VolcanoArkProvider.media_to_bytes(media)
|
||||||
|
ext = ".jpg" if "jpeg" in ct else (".webp" if "webp" in ct else ".png")
|
||||||
|
path = os.path.join(OUT_DIR, f"volcano_{mname}{ext}")
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(fileobj.getvalue())
|
||||||
|
print(f"✅ 出图成功 → {path}")
|
||||||
|
break
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
msg = str(exc)
|
||||||
|
flagged = any(k in msg.lower() for k in ("moderation", "safety", "sexual", "blocked", "sensitive", "审核", "违规", "content"))
|
||||||
|
print(f"❌ 失败{' · 命中内容审核' if flagged else ''}: {msg[:600]}")
|
||||||
|
|
||||||
|
print(f"\n完成。产物: {OUT_DIR}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -487,7 +487,7 @@ export function App() {
|
|||||||
if (res) setUser(res);
|
if (res) setUser(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string }) {
|
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string }) {
|
||||||
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
||||||
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
||||||
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
||||||
|
|||||||
@@ -654,13 +654,6 @@
|
|||||||
width: 18px; height: 18px;
|
width: 18px; height: 18px;
|
||||||
color: var(--black-alpha-24);
|
color: var(--black-alpha-24);
|
||||||
}
|
}
|
||||||
.image-workbench .iw-pv-h .pv-meta {
|
|
||||||
float: right;
|
|
||||||
font-family: var(--font-mono); font-size: 12px;
|
|
||||||
color: var(--black-alpha-48); letter-spacing: .04em;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.image-workbench .iw-pv-h .pv-meta b { color: var(--accent-black); font-weight: 600; }
|
|
||||||
.image-workbench .iw-pv-h .pv-line {
|
.image-workbench .iw-pv-h .pv-line {
|
||||||
font-size: 13px; color: var(--accent-black);
|
font-size: 13px; color: var(--accent-black);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -846,27 +839,6 @@
|
|||||||
/* 分组头里第一个导航头紧跟,无需额外上间距 */
|
/* 分组头里第一个导航头紧跟,无需额外上间距 */
|
||||||
.image-workbench .iw-cover-group-h + .iw-pv-h { margin-top: 0; }
|
.image-workbench .iw-cover-group-h + .iw-pv-h { margin-top: 0; }
|
||||||
|
|
||||||
/* 批次头(数量 + 状态) */
|
|
||||||
.image-workbench .gen-batch-h {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
}
|
|
||||||
.image-workbench .gen-batch-h .b-pic {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
background: var(--background-lighter);
|
|
||||||
border: 1px solid var(--border-faint);
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
display: grid; place-items: center;
|
|
||||||
color: var(--heat);
|
|
||||||
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
|
|
||||||
}
|
|
||||||
.image-workbench .gen-batch-h .b-meta { flex: 1; min-width: 0; }
|
|
||||||
.image-workbench .gen-batch-h .b-nm { font-size: 13px; font-weight: 600; color: var(--accent-black); }
|
|
||||||
.image-workbench .gen-batch-h .b-info {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-family: var(--font-mono); font-size: 12px;
|
|
||||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
|
||||||
}
|
|
||||||
/* 行30:批次底部操作行 · 与上方图片网格拉开间距(原 .gen-card-actions 仅 4px,过小) */
|
/* 行30:批次底部操作行 · 与上方图片网格拉开间距(原 .gen-card-actions 仅 4px,过小) */
|
||||||
.image-workbench .gen-batch-actions {
|
.image-workbench .gen-batch-actions {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
|||||||
@@ -466,6 +466,18 @@ export const api = {
|
|||||||
if (meta) for (const [k, v] of Object.entries(meta)) if (v) qs.set(`m_${k}`, v);
|
if (meta) for (const [k, v] of Object.entries(meta)) if (v) qs.set(`m_${k}`, v);
|
||||||
return request<Paginated<Asset>>(`/api/assets/?${qs.toString()}`);
|
return request<Paginated<Asset>>(`/api/assets/?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
|
// 图片趴(模特上身图/平台套图/自由创作)按生成批次成组分页:每批一张卡(封面+张数),点开看整批图
|
||||||
|
assetBatches(params: {
|
||||||
|
tab?: string; source?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
|
||||||
|
meta?: Record<string, string>;
|
||||||
|
} = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
const { meta, pageSize, ...rest } = params;
|
||||||
|
for (const [k, v] of Object.entries(rest)) if (v !== undefined && v !== "" && v !== null) qs.set(k, String(v));
|
||||||
|
if (pageSize) qs.set("page_size", String(pageSize));
|
||||||
|
if (meta) for (const [k, v] of Object.entries(meta)) if (v) qs.set(`m_${k}`, v);
|
||||||
|
return request<Paginated<import("./types").AssetBatch>>(`/api/assets/batches/?${qs.toString()}`);
|
||||||
|
},
|
||||||
// 资产库各 tab 计数(tab 徽标用,不必取全量)
|
// 资产库各 tab 计数(tab 徽标用,不必取全量)
|
||||||
assetSummary() {
|
assetSummary() {
|
||||||
return request<Record<string, number>>("/api/assets/summary/");
|
return request<Record<string, number>>("/api/assets/summary/");
|
||||||
@@ -478,6 +490,11 @@ export const api = {
|
|||||||
submitAssetReview(id: string) {
|
submitAssetReview(id: string) {
|
||||||
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
|
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
|
||||||
},
|
},
|
||||||
|
// 单图「加入资产库 / 取消加入」:置 in_library。加入 → 出现在资产库列表;取消 → 移出列表。
|
||||||
|
// 资产生成时已落库并扣费,此口只控制是否进资产库展示列表,不增删真实资产、不二次扣费。
|
||||||
|
setAssetLibrary(id: string, inLibrary: boolean) {
|
||||||
|
return request<{ id: string; in_library: boolean }>(`/api/assets/${id}/set-library/`, { method: "POST", body: JSON.stringify({ in_library: inLibrary }) });
|
||||||
|
},
|
||||||
// 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」
|
// 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」
|
||||||
assetFacets(tab?: string, metaKeys: string[] = []) {
|
assetFacets(tab?: string, metaKeys: string[] = []) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
@@ -563,10 +580,12 @@ export const api = {
|
|||||||
return request<Paginated<ModelConfig>>("/api/ai/models/");
|
return request<Paginated<ModelConfig>>("/api/ai/models/");
|
||||||
},
|
},
|
||||||
aiTasks() {
|
aiTasks() {
|
||||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
// 取一大页(后端上限 200):任务中心要按真实总量算 全部/已完成/失败 三个 tab 数,
|
||||||
|
// 只取默认首页(20 条)会把统计算偏,甚至误显示「全部失败」。
|
||||||
|
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200");
|
||||||
},
|
},
|
||||||
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
||||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string }) {
|
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string }) {
|
||||||
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
generateImageStatus(ids: string[]) {
|
generateImageStatus(ids: string[]) {
|
||||||
|
|||||||
@@ -230,4 +230,9 @@ body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important;
|
|||||||
.pack-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }
|
.pack-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }
|
||||||
.pack-clip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 18px 20px; overflow-y: auto; }
|
.pack-clip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 18px 20px; overflow-y: auto; }
|
||||||
.pack-clip video { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; border-radius: var(--r-sm); background: #000; display: block; }
|
.pack-clip video { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; border-radius: var(--r-sm); background: #000; display: block; }
|
||||||
|
/* 生成批次:单图卡(按钮重置 + 与视频片段同尺寸,悬停略亮) */
|
||||||
|
.pack-clip.as-btn { border: none; background: transparent; padding: 0; width: 100%; cursor: pointer; text-align: center; }
|
||||||
|
.pack-clip img { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; border-radius: var(--r-sm); background: var(--background-lighter); display: block; transition: opacity .15s; }
|
||||||
|
.pack-clip.as-btn:hover img { opacity: .88; }
|
||||||
|
.pack-clip .pack-clip-ph { width: 100%; aspect-ratio: 9 / 16; display: flex; align-items: center; justify-content: center; border-radius: var(--r-sm); background: var(--background-lighter); }
|
||||||
.pack-clip-name { font-size: 11px; color: var(--black-alpha-48); margin-top: 4px; text-align: center; letter-spacing: .02em; }
|
.pack-clip-name { font-size: 11px; color: var(--black-alpha-48); margin-top: 4px; text-align: center; letter-spacing: .02em; }
|
||||||
|
|||||||
@@ -433,6 +433,13 @@ const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
|
|||||||
const COUNT_OPTIONS = ["1", "2", "4"];
|
const COUNT_OPTIONS = ["1", "2", "4"];
|
||||||
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
|
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
|
||||||
const MODEL_COUNT_OPTIONS = ["1", "2", "4"];
|
const MODEL_COUNT_OPTIONS = ["1", "2", "4"];
|
||||||
|
|
||||||
|
// 生图模型选择(写入 localStorage,下次进页面读回)。默认火山。
|
||||||
|
const GEN_MODEL_KEY = "airshelf:imgwb:gen_model";
|
||||||
|
const GEN_MODEL_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "volcano", label: "火山 Seedream" },
|
||||||
|
{ value: "gpt-image", label: "gpt-image-2" },
|
||||||
|
];
|
||||||
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
|
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
|
||||||
|
|
||||||
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
|
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
|
||||||
@@ -514,7 +521,7 @@ export function ImageWorkbenchPage({
|
|||||||
modelConfigs: ModelConfig[];
|
modelConfigs: ModelConfig[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
navigate?: (page: Page) => void;
|
navigate?: (page: Page) => void;
|
||||||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string }) => Promise<{ assets: Asset[] } | null>;
|
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string }) => Promise<{ assets: Asset[] } | null>;
|
||||||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||||||
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
||||||
initialProductId?: string;
|
initialProductId?: string;
|
||||||
@@ -533,6 +540,12 @@ export function ImageWorkbenchPage({
|
|||||||
const [ratioW, setRatioW] = useState("");
|
const [ratioW, setRatioW] = useState("");
|
||||||
const [ratioH, setRatioH] = useState("");
|
const [ratioH, setRatioH] = useState("");
|
||||||
const [style, setStyle] = useState("auto");
|
const [style, setStyle] = useState("auto");
|
||||||
|
// 生图模型选择:默认火山(内衣等敏感品类不被审核拦,还原也更好),可切 gpt-image-2。持久化到 localStorage。
|
||||||
|
const [genModel, setGenModel] = useState<string>(() => {
|
||||||
|
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
|
||||||
|
});
|
||||||
|
const [genModelOpen, setGenModelOpen] = useState(false);
|
||||||
|
useEffect(() => { try { localStorage.setItem(GEN_MODEL_KEY, genModel); } catch { /* 忽略 */ } }, [genModel]);
|
||||||
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
||||||
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
||||||
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
||||||
@@ -698,7 +711,7 @@ export function ImageWorkbenchPage({
|
|||||||
};
|
};
|
||||||
setBatches((prev) => [...prev, newBatch]);
|
setBatches((prev) => [...prev, newBatch]);
|
||||||
try {
|
try {
|
||||||
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio });
|
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel });
|
||||||
setBatches((prev) => {
|
setBatches((prev) => {
|
||||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
|
const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
|
||||||
persistBatches(next);
|
persistBatches(next);
|
||||||
@@ -773,30 +786,50 @@ export function ImageWorkbenchPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 加入资产库 / 取消加入(批次级来回切;§4.18:就地反馈,亮在该批首图上,不弹全局窗) */
|
/* 单图 in_library 写库 + 本地乐观更新 + 失败回滚的公共逻辑。
|
||||||
function toggleAdoptBatch(batchId: string) {
|
真源是后端 Asset.in_library:加入 → 出现在资产库列表;取消 → 从列表移出。batch.adopted 同步成「整批是否全已入库」。 */
|
||||||
|
function applyLibraryState(batchId: string, assetIds: string[], next: boolean) {
|
||||||
|
const ids = new Set(assetIds.filter(Boolean));
|
||||||
|
if (!ids.size) return;
|
||||||
|
const sync = (b: GenBatch, want: (a: Asset) => boolean): GenBatch => {
|
||||||
|
const results = b.results.map((a) => (ids.has(a.id) ? { ...a, in_library: want(a) } : a));
|
||||||
|
const withId = results.filter((a) => a.id);
|
||||||
|
return { ...b, results, adopted: withId.length > 0 && withId.every((a) => a.in_library) };
|
||||||
|
};
|
||||||
setBatches((prev) => {
|
setBatches((prev) => {
|
||||||
const target = prev.find((b) => b.id === batchId);
|
const updated = prev.map((b) => (b.id === batchId ? sync(b, () => next) : b));
|
||||||
const willAdopt = !target?.adopted;
|
persistBatches(updated);
|
||||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
return updated;
|
||||||
persistBatches(next);
|
});
|
||||||
const firstId = target?.results[0]?.id;
|
void Promise.all([...ids].map((id) => api.setAssetLibrary(id, next))).catch(() => {
|
||||||
if (willAdopt && firstId) flashFeedback(`${batchId}:${firstId}`);
|
// 写库失败:回滚到改前状态(按 id 翻回 !next)
|
||||||
return next;
|
setBatches((prev) => {
|
||||||
|
const reverted = prev.map((b) => (b.id === batchId ? sync(b, () => !next) : b));
|
||||||
|
persistBatches(reverted);
|
||||||
|
return reverted;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 加入资产库 / 取消(单图级,§4.18:单图气泡"加入资产库",就地反馈不弹全局窗) */
|
/* 加入资产库 / 取消加入(批次级:全部已入库 → 整批移出,否则整批加入) */
|
||||||
|
function toggleAdoptBatch(batchId: string) {
|
||||||
|
const batch = batches.find((b) => b.id === batchId);
|
||||||
|
if (!batch) return;
|
||||||
|
const withId = batch.results.filter((a) => a.id);
|
||||||
|
if (!withId.length) return;
|
||||||
|
const next = !withId.every((a) => a.in_library);
|
||||||
|
if (next) flashFeedback(`${batchId}:${withId[0].id}`);
|
||||||
|
applyLibraryState(batchId, withId.map((a) => a.id), next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加入资产库 / 取消(单图级:只改被点的那一张,§4.18 就地反馈不弹全局窗) */
|
||||||
function toggleAdoptImage(batchId: string, assetId: string) {
|
function toggleAdoptImage(batchId: string, assetId: string) {
|
||||||
// 单图采用以批次级 adopted 表达(后端无单图采用接口),反馈落在被点的那张图上
|
if (!assetId) return;
|
||||||
setBatches((prev) => {
|
const asset = batches.find((b) => b.id === batchId)?.results.find((a) => a.id === assetId);
|
||||||
const target = prev.find((b) => b.id === batchId);
|
if (!asset) return;
|
||||||
const willAdopt = !target?.adopted;
|
const next = !asset.in_library;
|
||||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
if (next) flashFeedback(`${batchId}:${assetId}`);
|
||||||
persistBatches(next);
|
applyLibraryState(batchId, [assetId], next);
|
||||||
if (willAdopt) flashFeedback(`${batchId}:${assetId}`);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
|
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
|
||||||
@@ -887,13 +920,13 @@ export function ImageWorkbenchPage({
|
|||||||
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
|
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{(batch.results.length
|
{(batch.results.length
|
||||||
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id }))
|
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id, inLib: !!asset.in_library }))
|
||||||
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" }))
|
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "", inLib: false }))
|
||||||
).map(({ key, index, url, assetId }) => {
|
).map(({ key, index, url, assetId, inLib }) => {
|
||||||
const cellKey = `${batch.id}:${assetId || key}`;
|
const cellKey = `${batch.id}:${assetId || key}`;
|
||||||
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
|
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
|
||||||
return (
|
return (
|
||||||
<div className={`gen-image ${generating && !url ? "gen" : ""}${batch.adopted && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
|
<div className={`gen-image ${generating && !url ? "gen" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
|
||||||
{url ? (
|
{url ? (
|
||||||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||||||
) : (
|
) : (
|
||||||
@@ -907,11 +940,11 @@ export function ImageWorkbenchPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* ③ 左上「已采用」绿角标(批次 adopted 时常驻) */}
|
{/* ③ 左上「已入库」绿角标(该图已加入资产库时常驻) */}
|
||||||
{url && batch.adopted && (
|
{url && inLib && (
|
||||||
<span className="gen-adopt-badge">
|
<span className="gen-adopt-badge">
|
||||||
<Check size={11} />
|
<Check size={11} />
|
||||||
已采用
|
已入库
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */}
|
{/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */}
|
||||||
@@ -937,7 +970,7 @@ export function ImageWorkbenchPage({
|
|||||||
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
|
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
|
||||||
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
|
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
|
||||||
<Bookmark size={13} />
|
<Bookmark size={13} />
|
||||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
{inLib ? "取消加入资产库" : "加入资产库"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
|
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
@@ -971,13 +1004,10 @@ export function ImageWorkbenchPage({
|
|||||||
const failed = batch.status === "failed";
|
const failed = batch.status === "failed";
|
||||||
return (
|
return (
|
||||||
<Fragment key={batch.id}>
|
<Fragment key={batch.id}>
|
||||||
{/* 行34:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
|
{/* 行34:每批一个合并导航头——商品/模特(平台)+ 结果(数量·比例·状态)左对齐一块;
|
||||||
|
原右上「1 批·比例」与「N×」徽标系重复信息,已剔除 */}
|
||||||
<div className="iw-pv-h">
|
<div className="iw-pv-h">
|
||||||
<Quote className="quote-icon" />
|
<Quote className="quote-icon" />
|
||||||
<div className="pv-meta">
|
|
||||||
<b>1 批</b>
|
|
||||||
{mode === "model" ? ` · ${batch.ratio}` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="pv-line">
|
<div className="pv-line">
|
||||||
<span className="k">商品</span>
|
<span className="k">商品</span>
|
||||||
<span className="v">{batchProductTitle(batch)}</span>
|
<span className="v">{batchProductTitle(batch)}</span>
|
||||||
@@ -1000,18 +1030,17 @@ export function ImageWorkbenchPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="pv-line">
|
||||||
|
<span className="k">结果</span>
|
||||||
|
<span className="v">
|
||||||
|
{batch.count} 张 · {batch.ratio} · {generating ? "生成中" : failed ? "失败" : "已完成"}
|
||||||
|
{!generating && !failed && batch.results.some((a) => a.in_library)
|
||||||
|
? ` · ${batch.results.filter((a) => a.in_library).length} 张已入库`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="gen-card gen-batch-card">
|
<div className="gen-card gen-batch-card">
|
||||||
<div className="gen-batch-h">
|
|
||||||
<span className="b-pic">{batch.count}×</span>
|
|
||||||
<div className="b-meta">
|
|
||||||
<div className="b-nm">{batch.count} 张 · {batch.ratio}</div>
|
|
||||||
<div className="b-info">
|
|
||||||
{generating ? "生成中" : failed ? "失败" : "已完成"}
|
|
||||||
{batch.adopted && !generating && !failed ? " · 已加入资产库" : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{renderBatchGrid(batch)}
|
{renderBatchGrid(batch)}
|
||||||
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
|
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
|
||||||
<div className="gen-batch-actions">
|
<div className="gen-batch-actions">
|
||||||
@@ -1330,6 +1359,21 @@ export function ImageWorkbenchPage({
|
|||||||
{product?.title || "未选择 · 请在左侧商品空间选一个"}
|
{product?.title || "未选择 · 请在左侧商品空间选一个"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 生图模型选择(默认火山,可切 gpt-image-2;选择写入 localStorage) */}
|
||||||
|
<div className={`tb-menu-wrap chip-wrap${genModelOpen ? " open" : ""}`} data-filter="genmodel">
|
||||||
|
<button className="tb-chip" type="button" title="生图模型" onClick={() => setGenModelOpen((v) => !v)}>
|
||||||
|
<Sparkles size={12} />
|
||||||
|
<span className="lbl">{GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}</span>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||||
|
</button>
|
||||||
|
<div className="chip-menu">
|
||||||
|
{GEN_MODEL_OPTIONS.map((o) => (
|
||||||
|
<div className={`mi${genModel === o.value ? " selected" : ""}`} key={o.value} role="button" tabIndex={0} onClick={() => { setGenModel(o.value); setGenModelOpen(false); }}>
|
||||||
|
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{o.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
|
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
|
||||||
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
|
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
|
||||||
<LayoutGrid size={13} />
|
<LayoutGrid size={13} />
|
||||||
@@ -1551,7 +1595,7 @@ export function ImageWorkbenchPage({
|
|||||||
<WandSparkles size={13} />
|
<WandSparkles size={13} />
|
||||||
立即生成 (预估 ¥{(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})
|
立即生成 (预估 ¥{(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})
|
||||||
</button>
|
</button>
|
||||||
<div className="iw-cta-hint">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣{anyGenerating ? " · 有批次生成中,可继续提交" : ""}</div>
|
<div className="iw-cta-hint">// 生成即扣费 · 生成的图先在工作台 · 点「加入资产库」才进资产库列表{anyGenerating ? " · 有批次生成中,可继续提交" : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2,
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { SkeletonGrid } from "../components/loading";
|
import { SkeletonGrid } from "../components/loading";
|
||||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||||
import type { Asset } from "../types";
|
import type { Asset, AssetBatch } from "../types";
|
||||||
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
|
|
||||||
@@ -44,6 +44,9 @@ const LIB_TABS: Array<{ key: LibTab; label: string }> = [
|
|||||||
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
|
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 图片成品三类按「生成批次」成组展示(一次提交的多张图 = 一张批次卡,点开看整批);其余 tab 仍平铺
|
||||||
|
const BATCH_TABS: LibTab[] = ["tryon", "kits", "creations"];
|
||||||
|
|
||||||
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
|
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
|
||||||
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
||||||
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
|
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
|
||||||
@@ -92,6 +95,10 @@ function AssetDetailModal({ asset, close, onZoom }: {
|
|||||||
// 多图资产:当前选中的主图索引(缩略图切换)
|
// 多图资产:当前选中的主图索引(缩略图切换)
|
||||||
const [activeIdx, setActiveIdx] = useState(0);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
useEffect(() => { setActiveIdx(0); }, [asset?.id]);
|
useEffect(() => { setActiveIdx(0); }, [asset?.id]);
|
||||||
|
// 审核盾本地覆盖态:必须在提前 return 之前声明,否则 hook 数量随 asset 有无变化 → React 崩溃白屏
|
||||||
|
const [reviewOverride, setReviewOverride] = useState<string | null>(null);
|
||||||
|
const [submittingReview, setSubmittingReview] = useState(false);
|
||||||
|
useEffect(() => { setReviewOverride(null); }, [asset?.id]);
|
||||||
if (!mounted || !asset) return null;
|
if (!mounted || !asset) return null;
|
||||||
|
|
||||||
const files = asset.files || [];
|
const files = asset.files || [];
|
||||||
@@ -132,10 +139,8 @@ function AssetDetailModal({ asset, close, onZoom }: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 审核盾:仅送审类(角色/三视图/分镜)显示;灰盾可点提交。本地覆盖提交后即时反映,切资产清空
|
// 审核盾:仅送审类(角色/三视图/分镜)显示;灰盾可点提交。本地覆盖提交后即时反映,切资产清空
|
||||||
|
// (状态 hook 已上移到提前 return 之前)
|
||||||
const REVIEW_CATS = ["person", "tri_view", "storyboard"];
|
const REVIEW_CATS = ["person", "tri_view", "storyboard"];
|
||||||
const [reviewOverride, setReviewOverride] = useState<string | null>(null);
|
|
||||||
const [submittingReview, setSubmittingReview] = useState(false);
|
|
||||||
useEffect(() => { setReviewOverride(null); }, [asset?.id]);
|
|
||||||
const review = (reviewOverride ?? asset.review_status ?? "") as ReviewStatus;
|
const review = (reviewOverride ?? asset.review_status ?? "") as ReviewStatus;
|
||||||
async function submitReview() {
|
async function submitReview() {
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
@@ -437,6 +442,9 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
const [items, setItems] = useState<Asset[]>([]);
|
const [items, setItems] = useState<Asset[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 });
|
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 });
|
||||||
|
// 图片成品:按生成批次成组(点批次卡 → 弹窗看整批图片)
|
||||||
|
const [batches, setBatches] = useState<AssetBatch[]>([]);
|
||||||
|
const [openBatch, setOpenBatch] = useState<AssetBatch | null>(null);
|
||||||
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
|
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
|
||||||
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
|
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
|
||||||
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
|
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
|
||||||
@@ -456,26 +464,38 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
|
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
|
||||||
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
|
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
|
||||||
|
|
||||||
|
// 图片成品三类按批次成组展示;其余 tab 平铺
|
||||||
|
const isBatchTab = BATCH_TABS.includes(tab);
|
||||||
|
|
||||||
// 列表数据(分页 + 过滤)
|
// 列表数据(分页 + 过滤)
|
||||||
const [reloadFlag, setReloadFlag] = useState(0);
|
const [reloadFlag, setReloadFlag] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === "videopacks") { setLoading(false); return; } // 视频成品走素材包,不拉扁平资产
|
if (tab === "videopacks") { setLoading(false); return; } // 视频成品走素材包,不拉扁平资产
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.assetsPage({
|
const common = {
|
||||||
tab,
|
tab,
|
||||||
q: debouncedQuery || undefined,
|
q: debouncedQuery || undefined,
|
||||||
source: srcFilter || undefined,
|
source: srcFilter || undefined,
|
||||||
asset_type: kindFilter || undefined,
|
|
||||||
meta: metaFilter,
|
meta: metaFilter,
|
||||||
ordering: sortDesc ? "-created_at" : "created_at",
|
ordering: sortDesc ? "-created_at" : "created_at",
|
||||||
page,
|
page,
|
||||||
pageSize: LIB_PAGE_SIZE
|
pageSize: LIB_PAGE_SIZE
|
||||||
}).then((res) => { if (alive) { setItems(res.results); setTotal(res.count); } })
|
};
|
||||||
|
if (isBatchTab) {
|
||||||
|
// 图片成品:按生成批次成组(每批一张卡)
|
||||||
|
api.assetBatches(common)
|
||||||
|
.then((res) => { if (alive) { setBatches(res.results); setTotal(res.count); } })
|
||||||
|
.catch(() => { if (alive) { setBatches([]); setTotal(0); } })
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
} else {
|
||||||
|
api.assetsPage({ ...common, asset_type: kindFilter || undefined })
|
||||||
|
.then((res) => { if (alive) { setItems(res.results); setTotal(res.count); } })
|
||||||
.catch(() => { if (alive) { setItems([]); setTotal(0); } })
|
.catch(() => { if (alive) { setItems([]); setTotal(0); } })
|
||||||
.finally(() => { if (alive) setLoading(false); });
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
}
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
|
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
|
||||||
|
|
||||||
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
|
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
|
||||||
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
|
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
|
||||||
@@ -541,7 +561,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>资产库</h1>
|
<h1>资产库</h1>
|
||||||
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} · 视频 {counts.videopacks} 包</span></div>
|
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} 批 · 视频 {counts.videopacks} 包</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||||
@@ -674,9 +694,32 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{shown.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
|
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {isBatchTab ? "个批次" : "个资产"}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
|
||||||
|
|
||||||
{shown.length ? (
|
{isBatchTab ? (
|
||||||
|
batches.length ? (
|
||||||
|
<div className="packs-grid" id="batch-grid">
|
||||||
|
{batches.map((batch) => (
|
||||||
|
<article className="pack-card" key={batch.batch_id} onClick={() => setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
|
||||||
|
{editMode && onDelete && (
|
||||||
|
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.id)); }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="placeholder asset-thumb pack-thumb">
|
||||||
|
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||||
|
<span className="pack-count mono">{batch.count} 张</span>
|
||||||
|
</div>
|
||||||
|
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<SkeletonGrid count={8} />
|
||||||
|
) : (
|
||||||
|
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||||
|
)
|
||||||
|
) : shown.length ? (
|
||||||
<div className="asset-grid" id="asset-grid">
|
<div className="asset-grid" id="asset-grid">
|
||||||
{shown.map((asset) => {
|
{shown.map((asset) => {
|
||||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||||
@@ -771,6 +814,35 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图) */}
|
||||||
|
{openBatch && createPortal(
|
||||||
|
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
|
||||||
|
<div className="pack-modal">
|
||||||
|
<div className="pack-modal-h">
|
||||||
|
<div>
|
||||||
|
<h2>{openBatch.name}</h2>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 生成批次 · {openBatch.count} 张</span>
|
||||||
|
</div>
|
||||||
|
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pack-clip-grid">
|
||||||
|
{openBatch.items.map((a, i) => {
|
||||||
|
const cover = a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
|
||||||
|
return (
|
||||||
|
<button className="pack-clip as-btn" type="button" key={a.id} onClick={() => { setOpenBatch(null); setDetail(a); }} title="查看详情">
|
||||||
|
{cover ? <img src={cover} alt={a.name} loading="lazy" /> : <span className="pack-clip-ph ph-frame">无图</span>}
|
||||||
|
<div className="pack-clip-name mono">第 {i + 1} 张</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
|
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
|
||||||
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
||||||
<span className="ct">已选 <b>{selected.size}</b> 项</span>
|
<span className="ct">已选 <b>{selected.size}</b> 项</span>
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ export type Asset = {
|
|||||||
description: string;
|
description: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
origin_task?: string | null;
|
origin_task?: string | null;
|
||||||
|
// 是否在「资产库」列表展示:工作台生成的图默认 false(只在工作台),「加入资产库」后置 true
|
||||||
|
in_library?: boolean;
|
||||||
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
|
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
|
||||||
product?: string | null;
|
product?: string | null;
|
||||||
files?: Array<{
|
files?: Array<{
|
||||||
@@ -259,6 +261,17 @@ export type ModelEntity = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 图片趴生成批次:同一次提交的 N 张图为一批(资产库模特上身图/平台套图/自由创作按此成组展示)。
|
||||||
|
// 卡片显封面 + 张数;点开看整批图片(items)。
|
||||||
|
export type AssetBatch = {
|
||||||
|
batch_id: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
cover: string;
|
||||||
|
created_at: string;
|
||||||
|
items: Asset[];
|
||||||
|
};
|
||||||
|
|
||||||
// 视频成品「项目素材包」:某项目产出的全部视频片段归一个包(资产库视频成品按此展示)
|
// 视频成品「项目素材包」:某项目产出的全部视频片段归一个包(资产库视频成品按此展示)
|
||||||
export type VideoPack = {
|
export type VideoPack = {
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user