fix(ai): 平台套图改走商品主图参考 image_edit · 模特上身图锁商品+模特双图
- 平台套图(cover)以前纯文生图,不参考真实商品主图 → 出图与商品对不上; 现以商品主图为参考图1走 image_edit,锁包装一致性,有模特则参考图2=模特 - 新增 build_platform_cover_prompt_refs;model_url 解析对 cover 模式同样生效 - 补回归测试:cover 传商品主图、model 传商品主图+模特图、无主图回落文生图 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -576,6 +576,17 @@ def _find_entity_group(project, kind: str, label: str, group_id: str | None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio_to_image_size(ratio: str) -> str:
|
||||||
|
"""前端比例(1:1 / 3:4 / 9:16)→ gpt-image 支持的尺寸。竖屏统一 1024x1536,横屏 1536x1024。"""
|
||||||
|
return {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"3:4": "1024x1536",
|
||||||
|
"9:16": "1024x1536",
|
||||||
|
"4:3": "1536x1024",
|
||||||
|
"16:9": "1536x1024",
|
||||||
|
}.get((ratio or "").strip(), "1024x1024")
|
||||||
|
|
||||||
|
|
||||||
def _product_cover_url(product) -> str:
|
def _product_cover_url(product) -> str:
|
||||||
"""商品主图 URL:优先 cover_asset,其次标记为主图的商品图,再次首张商品图。无图返回 ''。"""
|
"""商品主图 URL:优先 cover_asset,其次标记为主图的商品图,再次首张商品图。无图返回 ''。"""
|
||||||
if product is None:
|
if product is None:
|
||||||
@@ -604,6 +615,50 @@ def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
|
|||||||
return " ".join(lines)
|
return " ".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
|
||||||
|
"""模特上身图 image_edit 提示词(refs 版):
|
||||||
|
参考图1=商品真实主图(锁商品外形/品牌/配色),参考图2=选中模特(锁人脸/身形/气质)。
|
||||||
|
生成「该模特自然展示/使用该商品」的电商效果图。"""
|
||||||
|
name = (getattr(product, "title", "") or "商品").strip()
|
||||||
|
lines = [f"参考图1是「{name}」的真实商品。"]
|
||||||
|
if has_model:
|
||||||
|
lines += [
|
||||||
|
"参考图2是出镜模特。请生成参考图2中的这位模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||||
|
"模特的五官、发型、肤色、身形与气质必须与参考图2高度一致,不要换人;",
|
||||||
|
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines += [
|
||||||
|
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||||
|
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||||
|
]
|
||||||
|
lines.append("自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")
|
||||||
|
if base_prompt and base_prompt.strip():
|
||||||
|
lines.append(base_prompt.strip())
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
|
||||||
|
"""平台套图 image_edit 提示词(refs 版):参考图1=商品真实主图(锁外形/品牌/配色/Logo),
|
||||||
|
有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。"""
|
||||||
|
name = (getattr(product, "title", "") or "商品").strip()
|
||||||
|
lines = [f"参考图1是「{name}」的真实商品主图。"]
|
||||||
|
if has_model:
|
||||||
|
lines += [
|
||||||
|
"参考图2是出镜模特。请生成参考图2中的这位模特展示参考图1中商品的电商平台套图(主图 / 封面 / 详情);",
|
||||||
|
"模特的五官、发型、肤色、身形必须与参考图2高度一致,不要换人;",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines.append("请基于该商品生成电商平台套图(主图 / 封面 / 详情排版),统一视觉风格;")
|
||||||
|
lines.append(
|
||||||
|
"商品的外形、品牌文字、配色、Logo、材质必须与参考图1高度一致,严禁改动或重新设计包装;"
|
||||||
|
"干净背景、电商主图构图、真实质感。"
|
||||||
|
)
|
||||||
|
if base_prompt and base_prompt.strip():
|
||||||
|
lines.append(base_prompt.strip())
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
|
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
|
||||||
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
||||||
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
||||||
@@ -1349,7 +1404,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) -> 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, ratio: str | None = None) -> list[AITask]:
|
||||||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||||||
|
|
||||||
@@ -1375,7 +1430,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
status=AITask.Status.CREATED,
|
status=AITask.Status.CREATED,
|
||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
||||||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product)},
|
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "ratio": str(ratio) if ratio else None},
|
||||||
estimated_cost=cost,
|
estimated_cost=cost,
|
||||||
)
|
)
|
||||||
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
||||||
@@ -1402,25 +1457,54 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
mode = str(payload.get("mode") or "image")
|
mode = str(payload.get("mode") or "image")
|
||||||
index = int(payload.get("index") or 0)
|
index = int(payload.get("index") or 0)
|
||||||
product_id = payload.get("product_id") or None
|
product_id = payload.get("product_id") or None
|
||||||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
# 模特上身图(mode=model 且绑了商品)= 该商品的商品图,归到对应商品的 AI 资产,不进人物库;
|
||||||
|
# 「生成演员」同样走 mode=model 但无 product_id,仍归人物库(PERSON)。
|
||||||
|
if mode == "model" and product_id:
|
||||||
|
category = Asset.Category.PRODUCT_IMAGE
|
||||||
|
else:
|
||||||
|
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||||||
model_config = task.model_config
|
model_config = task.model_config
|
||||||
provider = get_image_provider(model_config)
|
provider = get_image_provider(model_config)
|
||||||
reservation = task.credit_reservation
|
reservation = task.credit_reservation
|
||||||
# 商品三视图(商品详情页按 reference_product 提交):有真实商品主图 + 模型支持 image_edit →
|
# 出图策略(都优先 image_edit 锁真实素材,模型不支持/无素材才回落纯文生图):
|
||||||
# 以主图为参考锁包装一致性,否则回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。
|
# · 模特上身图(mode=model):参考图1=商品真实主图 + 参考图2=选中模特 → 生成「该模特用该商品」效果图;
|
||||||
ref_url = ""
|
# · 平台套图(mode=cover):参考图1=商品真实主图(+ 有模特则参考图2=模特)→ 锁包装一致性出套图;
|
||||||
|
# · 商品三视图(reference_product):参考图1=商品真实主图 → 锁包装一致性;
|
||||||
|
# · 其余(图片创作):纯文生图。
|
||||||
product = None
|
product = None
|
||||||
if bool(payload.get("reference_product")) and product_id:
|
if product_id:
|
||||||
from apps.products.models import Product
|
from apps.products.models import Product
|
||||||
|
|
||||||
product = Product.objects.filter(id=product_id).first()
|
product = Product.objects.filter(id=product_id).first()
|
||||||
if product is not None:
|
can_edit = hasattr(provider, "image_edit")
|
||||||
ref_url = _product_cover_url(product)
|
model_url = ""
|
||||||
use_edit = bool(ref_url) and hasattr(provider, "image_edit")
|
if payload.get("model_id"):
|
||||||
|
model_asset = Asset.objects.filter(id=payload.get("model_id")).first()
|
||||||
|
if model_asset is not None:
|
||||||
|
model_url = _asset_preview_url(model_asset)
|
||||||
|
product_url = _product_cover_url(product) if product is not None else ""
|
||||||
|
|
||||||
|
edit_images: list[str] = []
|
||||||
|
edit_prompt = ""
|
||||||
|
if mode == "model" and can_edit and product_url:
|
||||||
|
# 模特上身图:商品图必有,模特图可缺(缺则让模型自取真人模特)
|
||||||
|
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||||
|
edit_prompt = build_model_tryon_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
||||||
|
elif mode == "cover" and can_edit and product_url:
|
||||||
|
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
|
||||||
|
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)
|
||||||
|
elif bool(payload.get("reference_product")) and can_edit and product_url:
|
||||||
|
edit_images = [product_url]
|
||||||
|
edit_prompt = build_product_triview_prompt_refs(product, "")
|
||||||
|
use_edit = bool(edit_images)
|
||||||
try:
|
try:
|
||||||
if use_edit:
|
if use_edit:
|
||||||
edit_prompt = build_product_triview_prompt_refs(product, "")
|
if payload.get("reference_product"):
|
||||||
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=[ref_url], size="1536x1024")
|
size = "1536x1024" # 三视图固定横向
|
||||||
|
else:
|
||||||
|
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)
|
||||||
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)
|
||||||
@@ -1436,8 +1520,9 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
asset_id = uuid.uuid4()
|
asset_id = uuid.uuid4()
|
||||||
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
|
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
|
||||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||||
|
asset_label = {"model": "模特上身图", "cover": "平台套图", "image": "图片创作"}.get(mode, mode)
|
||||||
asset = Asset.objects.create(
|
asset = Asset.objects.create(
|
||||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {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,
|
||||||
# 记下生图时选中的商品,商品详情页据此只展示「该商品」的 AI 素材(而非全团队)
|
# 记下生图时选中的商品,商品详情页据此只展示「该商品」的 AI 素材(而非全团队)
|
||||||
metadata={"product_id": str(product_id)} if product_id else {},
|
metadata={"product_id": str(product_id)} if product_id else {},
|
||||||
|
|||||||
@@ -93,3 +93,78 @@ class NormalizeDraftTests(SimpleTestCase):
|
|||||||
self.assertEqual(len(draft["segments"]), 2)
|
self.assertEqual(len(draft["segments"]), 2)
|
||||||
self.assertEqual(draft["segments"][0]["narration"], "口播1")
|
self.assertEqual(draft["segments"][0]["narration"], "口播1")
|
||||||
self.assertEqual(draft["segments"][1]["visual"], "画面2")
|
self.assertEqual(draft["segments"][1]["visual"], "画面2")
|
||||||
|
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.accounts.models import Team, User
|
||||||
|
from apps.ai.services import enqueue_standalone_images
|
||||||
|
from apps.assets.models import Asset, AssetFile
|
||||||
|
from apps.billing.models import CreditAccount
|
||||||
|
from apps.products.models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class StandaloneImageReferenceTests(TestCase):
|
||||||
|
"""独立生图(平台套图 / 模特上身图)必须把商品真实主图(+ 模特图)作为参考图走 image_edit,
|
||||||
|
而不是纯文生图——回归保护 #图片生成没参考商品主图# 这个 bug。
|
||||||
|
(图像默认模型由迁移 seed 的 tokenssr:gpt-image-2 提供,get_image_provider 全程 mock。)"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="owner", password="pass")
|
||||||
|
self.team = Team.objects.create(name="T", owner=self.user)
|
||||||
|
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||||
|
# 商品 + 主图(带可访问 preview_url)
|
||||||
|
self.product = Product.objects.create(team=self.team, created_by=self.user, title="南卡 Lite Pro")
|
||||||
|
cover = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="主图", asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.UPLOAD, category=Asset.Category.PRODUCT_IMAGE,
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(asset=cover, object_key="c.png", bucket="b", content_type="image/png", preview_url="http://x/cover.png", is_primary=True)
|
||||||
|
self.product.cover_asset = cover
|
||||||
|
self.product.save(update_fields=["cover_asset"])
|
||||||
|
|
||||||
|
def _patch_provider(self):
|
||||||
|
# image_edit 返回值 + 媒体落库链路全部 mock,聚焦验证「传了哪些参考图」
|
||||||
|
provider = patch("apps.ai.services.get_image_provider").start()
|
||||||
|
prov = provider.return_value
|
||||||
|
prov.image_edit.return_value = {"data": [{"url": "http://x/out.png"}]}
|
||||||
|
prov.image_generation.return_value = {"data": [{"url": "http://x/out.png"}]}
|
||||||
|
prov.extract_first_media_url.return_value = "http://x/out.png"
|
||||||
|
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
|
||||||
|
media.return_value = (BytesIO(b"img"), "image/png")
|
||||||
|
store = patch("apps.ai.services.TosStorage").start()
|
||||||
|
stored = store.return_value.upload_fileobj.return_value
|
||||||
|
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
return prov
|
||||||
|
|
||||||
|
def test_cover_mode_references_product_main_image(self):
|
||||||
|
prov = self._patch_provider()
|
||||||
|
enqueue_standalone_images(team=self.team, user=self.user, prompt="平台套图", mode="cover", count=1, product_id=str(self.product.id), ratio="4:5")
|
||||||
|
prov.image_edit.assert_called_once()
|
||||||
|
self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/cover.png"])
|
||||||
|
prov.image_generation.assert_not_called()
|
||||||
|
|
||||||
|
def test_model_tryon_combines_product_and_model_images(self):
|
||||||
|
prov = self._patch_provider()
|
||||||
|
# 模特资产(PERSON)带 preview_url
|
||||||
|
model_asset = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="模特", asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(asset=model_asset, object_key="m.png", bucket="b", content_type="image/png", preview_url="http://x/model.png", is_primary=True)
|
||||||
|
enqueue_standalone_images(team=self.team, user=self.user, prompt="上身图", mode="model", count=1, product_id=str(self.product.id), model_id=str(model_asset.id), ratio="4:5")
|
||||||
|
prov.image_edit.assert_called_once()
|
||||||
|
self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/cover.png", "http://x/model.png"])
|
||||||
|
prov.image_generation.assert_not_called()
|
||||||
|
|
||||||
|
def test_cover_mode_falls_back_to_t2i_without_main_image(self):
|
||||||
|
prov = self._patch_provider()
|
||||||
|
self.product.cover_asset = None
|
||||||
|
self.product.save(update_fields=["cover_asset"])
|
||||||
|
enqueue_standalone_images(team=self.team, user=self.user, prompt="平台套图", mode="cover", count=1, product_id=str(self.product.id), ratio="4:5")
|
||||||
|
prov.image_generation.assert_called_once()
|
||||||
|
prov.image_edit.assert_not_called()
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ class GenerateImageView(APIView):
|
|||||||
count = 1
|
count = 1
|
||||||
product_id = str(request.data.get("product_id") or "").strip() or None
|
product_id = str(request.data.get("product_id") or "").strip() or None
|
||||||
reference_product = bool(request.data.get("reference_product"))
|
reference_product = bool(request.data.get("reference_product"))
|
||||||
|
model_id = str(request.data.get("model_id") or "").strip() or None
|
||||||
|
ratio = str(request.data.get("ratio") 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)
|
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, ratio=ratio)
|
||||||
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(
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from airshelf.celery import app
|
||||||
|
from apps.accounts.models import Team
|
||||||
|
|
||||||
|
|
||||||
|
@app.task
|
||||||
|
def ensure_team_notifications_task(team_id: str, user_id: str | None = None) -> None:
|
||||||
|
"""后台生成/补齐团队站内通知。
|
||||||
|
|
||||||
|
原先在 NotificationViewSet.list() 里同步执行:每次刷新都要读计费账本 +
|
||||||
|
对项目/资产/扣费流水逐条 get_or_create,几十次 DB 往返压在请求线程上,拖慢通知接口。
|
||||||
|
现改由 Celery 异步执行,请求只负责读现有通知,生成搬到后台 worker。
|
||||||
|
"""
|
||||||
|
from apps.ops.views import ensure_team_notifications # 延迟导入避免循环依赖
|
||||||
|
|
||||||
|
team = Team.objects.filter(id=team_id).first()
|
||||||
|
if team is None:
|
||||||
|
return
|
||||||
|
user = get_user_model().objects.filter(id=user_id).first() if user_id else None
|
||||||
|
ensure_team_notifications(team, user)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.accounts.models import Team, TeamMember, User
|
||||||
|
from apps.ai.models import AITask, ModelConfig, ModelProvider
|
||||||
|
from apps.assets.models import Asset
|
||||||
|
from apps.billing.models import CreditAccount, CreditLedger
|
||||||
|
from apps.ops.models import Notification
|
||||||
|
from apps.ops.views import ensure_team_notifications
|
||||||
|
from apps.products.models import Product
|
||||||
|
from apps.projects.models import Project
|
||||||
|
|
||||||
|
|
||||||
|
class BillingNotificationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="owner", password="pass")
|
||||||
|
self.team = Team.objects.create(name="T", owner=self.user)
|
||||||
|
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||||
|
CreditAccount.objects.create(team=self.team, balance="500.0000")
|
||||||
|
self.provider = ModelProvider.objects.create(name="ops-test-prov", display_name="TSR")
|
||||||
|
self.model = ModelConfig.objects.create(
|
||||||
|
provider=self.provider, name="ops-test-image", capability=ModelConfig.Capability.IMAGE
|
||||||
|
)
|
||||||
|
self.product = Product.objects.create(team=self.team, created_by=self.user, title="补水面膜")
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
team=self.team, name="补水面膜·短视频", product=self.product, created_by=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
def _charge(self, task_type, amount, balance_after):
|
||||||
|
task = AITask.objects.create(
|
||||||
|
team=self.team,
|
||||||
|
project=self.project,
|
||||||
|
task_type=task_type,
|
||||||
|
model_config=self.model,
|
||||||
|
idempotency_key=f"k-{task_type}-{amount}",
|
||||||
|
actual_cost=Decimal(amount),
|
||||||
|
)
|
||||||
|
CreditLedger.objects.create(
|
||||||
|
team=self.team,
|
||||||
|
user=self.user,
|
||||||
|
project=self.project,
|
||||||
|
task=task,
|
||||||
|
ledger_type=CreditLedger.Type.CHARGE,
|
||||||
|
amount=Decimal(amount),
|
||||||
|
balance_after=Decimal(balance_after),
|
||||||
|
reason="AI 任务扣费",
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def test_charge_becomes_billing_notification_with_real_amount(self):
|
||||||
|
self._charge("product_image", "0.32", "499.68")
|
||||||
|
self._charge("video_segment", "18.40", "481.28")
|
||||||
|
|
||||||
|
ensure_team_notifications(self.team, self.user)
|
||||||
|
|
||||||
|
billing = Notification.objects.filter(team=self.team, notification_type=Notification.Type.BILLING)
|
||||||
|
# 每笔扣费一条计费消息,且费用不为空、为真实金额
|
||||||
|
costs = sorted(n.cost_label for n in billing)
|
||||||
|
self.assertIn("¥0.32", costs)
|
||||||
|
self.assertIn("¥18.40", costs)
|
||||||
|
self.assertFalse(billing.filter(cost_label__in=["", "-"]).exists())
|
||||||
|
self.assertTrue(billing.filter(title__contains="图生成").exists())
|
||||||
|
self.assertTrue(billing.filter(title__contains="视频生成").exists())
|
||||||
|
|
||||||
|
def test_project_notification_shows_aggregated_spend(self):
|
||||||
|
self._charge("product_image", "0.32", "499.68")
|
||||||
|
self._charge("video_segment", "18.40", "481.28")
|
||||||
|
|
||||||
|
ensure_team_notifications(self.team, self.user)
|
||||||
|
|
||||||
|
proj_note = Notification.objects.get(
|
||||||
|
team=self.team, notification_type=Notification.Type.TASK, title__contains="补水面膜"
|
||||||
|
)
|
||||||
|
self.assertEqual(proj_note.cost_label, "¥18.72") # 0.32 + 18.40
|
||||||
|
|
||||||
|
def test_existing_dash_cost_is_backfilled(self):
|
||||||
|
# 早期建的项目通知费用是占位「-」;补上扣费后再跑 ensure 应回填真实金额
|
||||||
|
ensure_team_notifications(self.team, self.user)
|
||||||
|
note = Notification.objects.get(team=self.team, title__contains="补水面膜")
|
||||||
|
self.assertEqual(note.cost_label, "-")
|
||||||
|
|
||||||
|
self._charge("product_image", "0.32", "499.68")
|
||||||
|
ensure_team_notifications(self.team, self.user)
|
||||||
|
note.refresh_from_db()
|
||||||
|
self.assertEqual(note.cost_label, "¥0.32")
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
from django.db.models import Q
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db.models import Q, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@@ -14,7 +17,7 @@ class NotificationPagination(PageNumberPagination):
|
|||||||
max_page_size = 100
|
max_page_size = 100
|
||||||
|
|
||||||
from apps.assets.models import Asset
|
from apps.assets.models import Asset
|
||||||
from apps.billing.models import CreditAccount
|
from apps.billing.models import CreditAccount, CreditLedger
|
||||||
from apps.common.api import TeamScopedViewSetMixin
|
from apps.common.api import TeamScopedViewSetMixin
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
|
|
||||||
@@ -32,6 +35,20 @@ def project_stage_label(project):
|
|||||||
}.get(project.current_stage, "Stage 1 · 脚本")
|
}.get(project.current_stage, "Stage 1 · 脚本")
|
||||||
|
|
||||||
|
|
||||||
|
# AI 任务类型 → 计费消息里的中文阶段标签(脚本/图片/视频/配音都会扣费)
|
||||||
|
TASK_TYPE_LABEL = {
|
||||||
|
"script_generation": "脚本生成",
|
||||||
|
"script_optimization": "脚本优化",
|
||||||
|
"product_image": "商品图生成",
|
||||||
|
"person_image": "模特图生成",
|
||||||
|
"scene_image": "场景图生成",
|
||||||
|
"storyboard": "故事板生成",
|
||||||
|
"video_segment": "视频生成",
|
||||||
|
"voiceover": "配音生成",
|
||||||
|
"export": "成片导出",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def project_priority(project):
|
def project_priority(project):
|
||||||
if project.status == Project.Status.COMPLETED:
|
if project.status == Project.Status.COMPLETED:
|
||||||
return Notification.Priority.OK
|
return Notification.Priority.OK
|
||||||
@@ -57,11 +74,23 @@ def ensure_team_notifications(team, user):
|
|||||||
dedupe_key=dedupe_key,
|
dedupe_key=dedupe_key,
|
||||||
defaults=payload,
|
defaults=payload,
|
||||||
)
|
)
|
||||||
# 已存在的旧通知补齐「处理记录」timeline(早期建的没存),让历史消息也能显示处理记录
|
if created:
|
||||||
|
return
|
||||||
|
# 已存在的旧通知:补齐早期没存的字段(历史消息也能显示),不重建
|
||||||
|
updates = []
|
||||||
|
# 1) 处理记录 timeline
|
||||||
timeline = (payload.get("metadata") or {}).get("timeline")
|
timeline = (payload.get("metadata") or {}).get("timeline")
|
||||||
if not created and timeline and not (obj.metadata or {}).get("timeline"):
|
if timeline and not (obj.metadata or {}).get("timeline"):
|
||||||
obj.metadata = {**(obj.metadata or {}), "timeline": timeline}
|
obj.metadata = {**(obj.metadata or {}), "timeline": timeline}
|
||||||
obj.save(update_fields=["metadata", "updated_at"])
|
updates.append("metadata")
|
||||||
|
# 2) 费用:早期建的是占位「-」,现在能从计费账本算出真实金额了就回填
|
||||||
|
new_cost = payload.get("cost_label")
|
||||||
|
if new_cost and new_cost != "-" and obj.cost_label in ("", "-"):
|
||||||
|
obj.cost_label = new_cost
|
||||||
|
updates.append("cost_label")
|
||||||
|
if updates:
|
||||||
|
updates.append("updated_at")
|
||||||
|
obj.save(update_fields=updates)
|
||||||
|
|
||||||
create_once(
|
create_once(
|
||||||
"system:welcome",
|
"system:welcome",
|
||||||
@@ -80,6 +109,11 @@ def ensure_team_notifications(team, user):
|
|||||||
|
|
||||||
for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]:
|
for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]:
|
||||||
product_title = project.product.title if project.product_id else "未绑定商品"
|
product_title = project.product.title if project.product_id else "未绑定商品"
|
||||||
|
# 项目累计花费 = 该项目所有 AI 扣费流水之和(脚本/图片/视频/导出),无扣费则显示「-」
|
||||||
|
project_spend = project.credit_ledgers.filter(
|
||||||
|
ledger_type=CreditLedger.Type.CHARGE
|
||||||
|
).aggregate(total=Sum("amount"))["total"]
|
||||||
|
project_cost = f"¥{project_spend:.2f}" if project_spend else "-"
|
||||||
create_once(
|
create_once(
|
||||||
f"project:{project.id}:status:{project.status}:{project.current_stage}",
|
f"project:{project.id}:status:{project.status}:{project.current_stage}",
|
||||||
notification_type=Notification.Type.TASK,
|
notification_type=Notification.Type.TASK,
|
||||||
@@ -91,7 +125,7 @@ def ensure_team_notifications(team, user):
|
|||||||
project=project,
|
project=project,
|
||||||
stage=project_stage_label(project),
|
stage=project_stage_label(project),
|
||||||
owner_label=project.created_by.username if project.created_by_id else "成员",
|
owner_label=project.created_by.username if project.created_by_id else "成员",
|
||||||
cost_label="-",
|
cost_label=project_cost,
|
||||||
related_url=f"pipeline.html?project_id={project.id}",
|
related_url=f"pipeline.html?project_id={project.id}",
|
||||||
metadata={
|
metadata={
|
||||||
"status": project.status,
|
"status": project.status,
|
||||||
@@ -104,7 +138,10 @@ def ensure_team_notifications(team, user):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
for asset in Asset.objects.filter(team=team).select_related("created_by").order_by("-updated_at")[:3]:
|
for asset in Asset.objects.filter(team=team).select_related("created_by", "origin_task").order_by("-updated_at")[:3]:
|
||||||
|
# AI 生成的资产带 origin_task,可取其实际扣费;手动上传的资产无扣费 → 「-」
|
||||||
|
origin = asset.origin_task
|
||||||
|
asset_cost = f"¥{origin.actual_cost:.2f}" if origin and origin.actual_cost else "-"
|
||||||
create_once(
|
create_once(
|
||||||
f"asset:{asset.id}:created",
|
f"asset:{asset.id}:created",
|
||||||
notification_type=Notification.Type.TASK,
|
notification_type=Notification.Type.TASK,
|
||||||
@@ -115,7 +152,7 @@ def ensure_team_notifications(team, user):
|
|||||||
source="资产库",
|
source="资产库",
|
||||||
stage="资产入库",
|
stage="资产入库",
|
||||||
owner_label=asset.created_by.username if asset.created_by_id else "成员",
|
owner_label=asset.created_by.username if asset.created_by_id else "成员",
|
||||||
cost_label="-",
|
cost_label=asset_cost,
|
||||||
related_url="library.html",
|
related_url="library.html",
|
||||||
metadata={
|
metadata={
|
||||||
"asset_id": str(asset.id),
|
"asset_id": str(asset.id),
|
||||||
@@ -128,6 +165,53 @@ def ensure_team_notifications(team, user):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 计费消息:把真实扣费流水(图片/视频/脚本/配音生成)写成「计费」类通知,带真实金额。
|
||||||
|
# 原先 ensure_team_notifications 完全没读账本,所以计费消息的费用都是空的。
|
||||||
|
# 计费通知覆盖「消息保留期(90 天)」内的全部扣费流水,而不是固定最近 10 条 ——
|
||||||
|
# 否则计费 tab 计数会被卡在 10,与实际扣费条数严重不符。上限 500 仅作暴走兜底。
|
||||||
|
charge_window_start = timezone.now() - timedelta(days=90)
|
||||||
|
recent_charges = (
|
||||||
|
CreditLedger.objects.filter(
|
||||||
|
team=team,
|
||||||
|
ledger_type=CreditLedger.Type.CHARGE,
|
||||||
|
created_at__gte=charge_window_start,
|
||||||
|
)
|
||||||
|
.select_related("task", "project", "user")
|
||||||
|
.order_by("-created_at")[:500]
|
||||||
|
)
|
||||||
|
for ledger in recent_charges:
|
||||||
|
task = ledger.task
|
||||||
|
type_label = TASK_TYPE_LABEL.get(task.task_type, "AI 生成") if task else "AI 生成"
|
||||||
|
project_name = ledger.project.name if ledger.project_id else ""
|
||||||
|
amount_text = f"¥{ledger.amount:.2f}"
|
||||||
|
scope = f" · {project_name}" if project_name else ""
|
||||||
|
create_once(
|
||||||
|
f"billing:charge:{ledger.id}",
|
||||||
|
notification_type=Notification.Type.BILLING,
|
||||||
|
priority=Notification.Priority.INFO,
|
||||||
|
title=f"{type_label}已扣费 {amount_text}",
|
||||||
|
brief=f"{ledger.reason or 'AI 任务扣费'}{scope} · 扣费后余额 ¥{ledger.balance_after:.2f}",
|
||||||
|
body=(
|
||||||
|
f"本次{type_label}消耗 {amount_text},来自真实计费账本(CreditLedger)。"
|
||||||
|
f"扣费后团队余额为 ¥{ledger.balance_after:.2f}。明细可在消费页查看完整账本。"
|
||||||
|
),
|
||||||
|
source="计费中心",
|
||||||
|
project=ledger.project,
|
||||||
|
stage=type_label,
|
||||||
|
owner_label=ledger.user.username if ledger.user_id else "系统",
|
||||||
|
cost_label=amount_text,
|
||||||
|
related_url="account.html",
|
||||||
|
metadata={
|
||||||
|
"ledger_id": str(ledger.id),
|
||||||
|
"amount": str(ledger.amount),
|
||||||
|
"timeline": [
|
||||||
|
[_step_time(ledger.created_at), f"{type_label}提交并预留额度"],
|
||||||
|
[_step_time(ledger.created_at), f"实际扣费 {amount_text}"],
|
||||||
|
[_step_time(ledger.created_at), f"扣费后余额 ¥{ledger.balance_after:.2f}"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
account, _ = CreditAccount.objects.get_or_create(team=team)
|
account, _ = CreditAccount.objects.get_or_create(team=team)
|
||||||
if account.balance <= 100:
|
if account.balance <= 100:
|
||||||
create_once(
|
create_once(
|
||||||
@@ -174,8 +258,23 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
queryset = queryset.filter(is_read=False)
|
queryset = queryset.filter(is_read=False)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def _refresh_notifications(self, request):
|
||||||
|
"""生成/补齐通知:搬到 Celery 后台执行,不再阻塞请求。
|
||||||
|
- 首次(团队还没有任何通知)同步跑一次,避免初次打开面板是空的;
|
||||||
|
- 之后每团队最多每 60s 触发一次后台刷新,其余请求只读现有数据 → 接口秒回。
|
||||||
|
"""
|
||||||
|
team = self.get_team()
|
||||||
|
if not Notification.objects.filter(team=team).exists():
|
||||||
|
ensure_team_notifications(team, request.user)
|
||||||
|
return
|
||||||
|
if cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60):
|
||||||
|
from .tasks import ensure_team_notifications_task
|
||||||
|
|
||||||
|
user_id = str(request.user.id) if getattr(request.user, "id", None) else None
|
||||||
|
ensure_team_notifications_task.delay(str(team.id), user_id)
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
ensure_team_notifications(self.get_team(), request.user)
|
self._refresh_notifications(request)
|
||||||
response = super().list(request, *args, **kwargs)
|
response = super().list(request, *args, **kwargs)
|
||||||
data = response.data
|
data = response.data
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
|||||||
@@ -732,8 +732,9 @@
|
|||||||
/* ════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════
|
||||||
行31/32/33 · 批次卡 + 气泡菜单
|
行31/32/33 · 批次卡 + 气泡菜单
|
||||||
════════════════════════════════════════════════ */
|
════════════════════════════════════════════════ */
|
||||||
/* 批次卡:复用 .gen-card,批次间拉开间距 */
|
/* 批次卡:复用 .gen-card。每批各带一个导航头(.iw-pv-h),批次间靠「卡片→下一个头」拉开间距;
|
||||||
.image-workbench .gen-batch-card + .gen-batch-card { margin-top: 18px; }
|
首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */
|
||||||
|
.image-workbench .gen-batch-card + .iw-pv-h { margin-top: 22px; }
|
||||||
/* 批次头(数量 + 状态) */
|
/* 批次头(数量 + 状态) */
|
||||||
.image-workbench .gen-batch-h {
|
.image-workbench .gen-batch-h {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ export const api = {
|
|||||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
return request<Paginated<AITask>>("/api/ai/tasks/");
|
||||||
},
|
},
|
||||||
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
||||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) {
|
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: 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[]) {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.pd-overview .ov-card { height: 100%; box-sizing: border-box; }
|
.pd-overview .ov-card { height: 100%; box-sizing: border-box; }
|
||||||
|
/* 编辑商品信息时左栏变高:右侧快速操作不跟随拉伸,而是锁定为进编辑前(查看态)的高度,
|
||||||
|
整体大小保持不变。高度值由组件用 ref 实测后内联到 .ov-actions(见 products.tsx)。 */
|
||||||
|
.pd-overview:has(.ov-main.editing) { align-items: start; }
|
||||||
.pd-overview .ov-card {
|
.pd-overview .ov-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
|
|||||||
@@ -779,22 +779,6 @@ export function ImageWorkbenchPage({
|
|||||||
|
|
||||||
const hasResults = batches.length > 0;
|
const hasResults = batches.length > 0;
|
||||||
|
|
||||||
/* 按商品分组(行34):各商品各自一个导航头,避免多商品批次被并到同一个头里。
|
|
||||||
保持首次出现顺序;productId 缺失的旧批次归到当前商品组。 */
|
|
||||||
const batchGroups = (() => {
|
|
||||||
const order: string[] = [];
|
|
||||||
const map = new Map<string, { key: string; title: string; items: GenBatch[] }>();
|
|
||||||
for (const b of batches) {
|
|
||||||
const key = b.productId || product?.id || "—";
|
|
||||||
if (!map.has(key)) {
|
|
||||||
order.push(key);
|
|
||||||
map.set(key, { key, title: b.productTitle || product?.title || "未选择", items: [] });
|
|
||||||
}
|
|
||||||
map.get(key)!.items.push(b);
|
|
||||||
}
|
|
||||||
return order.map((k) => map.get(k)!);
|
|
||||||
})();
|
|
||||||
|
|
||||||
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */
|
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */
|
||||||
function renderBatchGrid(batch: GenBatch) {
|
function renderBatchGrid(batch: GenBatch) {
|
||||||
const generating = batch.status === "generating";
|
const generating = batch.status === "generating";
|
||||||
@@ -854,7 +838,15 @@ export function ImageWorkbenchPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 批次列表(行31/32/33):每个批次一行卡片,底部一组操作 + 底部「更多」气泡 ── */
|
/* 批次所属商品名:优先生成时记下的 productTitle;旧批次缺失则用 productId 或成图的 asset.product
|
||||||
|
(后端已解析归属商品)反查 products——绝不回退到「当前选中商品」(那正是会显示错名的根因)。 */
|
||||||
|
function batchProductTitle(batch: GenBatch): string {
|
||||||
|
if (batch.productTitle) return batch.productTitle;
|
||||||
|
const pid = batch.productId || batch.results.find((a) => a.product)?.product || "";
|
||||||
|
return products.find((p) => p.id === pid)?.title || "未选择";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 批次列表(行31/32/33):每批各一个商品导航头 + 一张卡片 + 底部操作/气泡 ── */
|
||||||
function renderBatchCards(list: GenBatch[]) {
|
function renderBatchCards(list: GenBatch[]) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -862,7 +854,32 @@ export function ImageWorkbenchPage({
|
|||||||
const generating = batch.status === "generating";
|
const generating = batch.status === "generating";
|
||||||
const failed = batch.status === "failed";
|
const failed = batch.status === "failed";
|
||||||
return (
|
return (
|
||||||
<div className="gen-card gen-batch-card" key={batch.id}>
|
<Fragment key={batch.id}>
|
||||||
|
{/* 行34:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
|
||||||
|
<div className="iw-pv-h">
|
||||||
|
<Quote className="quote-icon" />
|
||||||
|
<div className="pv-meta">
|
||||||
|
<b>1 批</b>
|
||||||
|
{mode === "model" ? ` · ${batch.ratio}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="pv-line">
|
||||||
|
<span className="k">商品</span>
|
||||||
|
<span className="v">{batchProductTitle(batch)}</span>
|
||||||
|
</div>
|
||||||
|
{mode === "cover" && (
|
||||||
|
<div className="pv-line">
|
||||||
|
<span className="k">平台</span>
|
||||||
|
<span className="v">
|
||||||
|
{pickedIds.length
|
||||||
|
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
|
||||||
|
.map((p) => p.name)
|
||||||
|
.join("、")
|
||||||
|
: "未选择"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="gen-card gen-batch-card">
|
||||||
<div className="gen-batch-h">
|
<div className="gen-batch-h">
|
||||||
<span className="b-pic">{batch.count}×</span>
|
<span className="b-pic">{batch.count}×</span>
|
||||||
<div className="b-meta">
|
<div className="b-meta">
|
||||||
@@ -902,6 +919,7 @@ export function ImageWorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@@ -1343,35 +1361,8 @@ export function ImageWorkbenchPage({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||||
{/* 行34:按商品分组——每个商品一个独立导航头 + 自己的批次列表 */}
|
{/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */}
|
||||||
{batchGroups.map((group) => (
|
{renderBatchCards(batches)}
|
||||||
<Fragment key={group.key}>
|
|
||||||
<div className="iw-pv-h">
|
|
||||||
<Quote className="quote-icon" />
|
|
||||||
<div className="pv-meta">
|
|
||||||
<b>{group.items.length} 批</b>
|
|
||||||
{mode === "model" ? ` · ${ratio}` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="pv-line">
|
|
||||||
<span className="k">商品</span>
|
|
||||||
<span className="v">{group.title}</span>
|
|
||||||
</div>
|
|
||||||
{mode === "cover" && (
|
|
||||||
<div className="pv-line">
|
|
||||||
<span className="k">平台</span>
|
|
||||||
<span className="v">
|
|
||||||
{pickedIds.length
|
|
||||||
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
|
|
||||||
.map((p) => p.name)
|
|
||||||
.join("、")
|
|
||||||
: "未选择"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{renderBatchCards(group.items)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1330,42 +1330,8 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
|||||||
.modal-x { margin-left: auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; }
|
.modal-x { margin-left: auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; }
|
||||||
.modal-x:hover { background: var(--bg-soft); }
|
.modal-x:hover { background: var(--bg-soft); }
|
||||||
|
|
||||||
.msg-workbench { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 18px; min-height: 640px; }
|
/* 消息中心样式统一由 messages-page.css(对齐 design 稿)提供;此处旧版 V1 .msg-* 规则
|
||||||
.msg-panel { background: var(--card); border: 1px solid var(--border); min-width: 0; }
|
用的是 --ink/--orange/--bg-soft 旧 token,会漏样式(灰底标签、搜索框描边、记录分隔线),已移除。 */
|
||||||
.msg-panel-h { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
|
||||||
.msg-panel-h .ti { font-weight: 600; }
|
|
||||||
.msg-filters { display: flex; gap: 6px; padding: 12px; flex-wrap: wrap; }
|
|
||||||
.msg-filter { border: 1px solid var(--border); background: var(--card); padding: 5px 9px; font-size: 12px; color: var(--ink-2); }
|
|
||||||
.msg-filter.active { background: var(--orange-tint); border-color: var(--orange-soft); color: var(--orange); font-weight: 600; }
|
|
||||||
.msg-filter .ct { margin-left: 6px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.msg-search { display: flex; align-items: center; gap: 8px; margin: 0 12px 12px; border: 1px solid var(--border); background: var(--bg-soft); padding: 8px 10px; color: var(--ink-3); }
|
|
||||||
.msg-search input { border: 0; background: transparent; flex: 1; }
|
|
||||||
.msg-list { border-top: 1px solid var(--border); }
|
|
||||||
.msg-item { width: 100%; display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border); text-align: left; background: var(--card); }
|
|
||||||
.msg-item:hover, .msg-item.active { background: var(--orange-tint); }
|
|
||||||
.msg-type-ic { width: 28px; height: 28px; border: 1px solid var(--border); background: var(--bg-soft); display: grid; place-items: center; color: var(--orange); }
|
|
||||||
.msg-item-row, .msg-item-foot { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.msg-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--orange); }
|
|
||||||
.msg-item-title { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.msg-time, .msg-priority { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
|
||||||
.msg-brief { display: block; color: var(--ink-2); font-size: 12px; line-height: 1.5; margin: 4px 0 7px; }
|
|
||||||
.msg-detail { display: flex; flex-direction: column; }
|
|
||||||
.msg-detail-body { padding: 20px; flex: 1; }
|
|
||||||
.msg-detail-top { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
|
||||||
.msg-detail-title h2 { font-size: 18px; line-height: 1.35; }
|
|
||||||
.msg-detail-title .meta { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.msg-body-text { color: var(--ink-2); line-height: 1.7; margin-bottom: 16px; }
|
|
||||||
.msg-props { display: grid; grid-template-columns: 90px 1fr; gap: 0; border: 1px solid var(--border); margin-bottom: 16px; }
|
|
||||||
.msg-props .k, .msg-props .v { padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
|
|
||||||
.msg-props .k { background: var(--bg-soft); color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.msg-timeline { border: 1px solid var(--border); }
|
|
||||||
.msg-timeline-h { padding: 10px 12px; border-bottom: 1px solid var(--border); font-weight: 600; }
|
|
||||||
.msg-step { display: grid; grid-template-columns: 70px 1fr; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
|
||||||
.msg-step:last-child { border-bottom: 0; }
|
|
||||||
.msg-step .t { color: var(--orange); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
|
||||||
.msg-detail-f { display: flex; gap: 8px; align-items: center; padding: 14px 16px; border-top: 1px solid var(--border); background: var(--bg-soft); }
|
|
||||||
.msg-foot-note { display: flex; justify-content: space-between; margin-top: 12px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
|
||||||
.msg-foot-note button { color: var(--orange); }
|
|
||||||
|
|
||||||
.factory-hero { display: grid; gap: 18px; margin-bottom: 26px; }
|
.factory-hero { display: grid; gap: 18px; margin-bottom: 26px; }
|
||||||
.factory-card { background: var(--card); border: 1px solid var(--border); position: relative; padding: 24px; }
|
.factory-card { background: var(--card); border: 1px solid var(--border); position: relative; padding: 24px; }
|
||||||
@@ -1429,7 +1395,7 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
|||||||
.switch input:checked + .slider::before { transform: translateX(18px); }
|
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.wizard, .create-product-layout, .product-detail-layout, .top-grid, .team-top, .msg-workbench, .factory-body, .tool-layout, .demo-layout, .settings-layout, .overview-grid {
|
.wizard, .create-product-layout, .product-detail-layout, .top-grid, .team-top, .factory-body, .tool-layout, .demo-layout, .settings-layout, .overview-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.wiz-preview, .steps, .create-upload-zone, .settings-side { position: static; }
|
.wiz-preview, .steps, .create-upload-zone, .settings-side { position: static; }
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"ts": "2026-06-17T11:26:06.782Z",
|
"ts": "2026-06-17T13:32:23.263Z",
|
||||||
"base": "http://127.0.0.1:5174",
|
"base": "http://127.0.0.1:5174",
|
||||||
"apiChecks": [
|
"apiChecks": [
|
||||||
{
|
{
|
||||||
"name": "page_size 生效 /api/assets/",
|
"name": "page_size 生效 /api/assets/",
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"detail": "count=248 返回=200 next=有"
|
"detail": "count=261 返回=200 next=有"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "page_size 生效 /api/products/",
|
"name": "page_size 生效 /api/products/",
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"detail": "count=10 返回=10 next=无"
|
"detail": "count=11 返回=11 next=无"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "page_size 生效 /api/projects/",
|
"name": "page_size 生效 /api/projects/",
|
||||||
@@ -21,25 +21,25 @@
|
|||||||
"name": "延迟 /api/assets/?page_size=200",
|
"name": "延迟 /api/assets/?page_size=200",
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"soft": true,
|
"soft": true,
|
||||||
"detail": "739ms / 参考 1200ms (HTTP 200)"
|
"detail": "808ms / 参考 1200ms (HTTP 200)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "延迟 /api/products/?page_size=200",
|
"name": "延迟 /api/products/?page_size=200",
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"soft": true,
|
"soft": true,
|
||||||
"detail": "573ms / 参考 1200ms (HTTP 200)"
|
"detail": "585ms / 参考 1200ms (HTTP 200)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "延迟 /api/projects/?page_size=200",
|
"name": "延迟 /api/projects/?page_size=200",
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"soft": true,
|
"soft": true,
|
||||||
"detail": "482ms / 参考 1200ms (HTTP 200)"
|
"detail": "488ms / 参考 1200ms (HTTP 200)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "延迟 /api/ops/notifications/?page_size=100",
|
"name": "延迟 /api/ops/notifications/?page_size=100",
|
||||||
"pass": false,
|
"pass": false,
|
||||||
"soft": true,
|
"soft": true,
|
||||||
"detail": "1389ms / 参考 1200ms (HTTP 200)"
|
"detail": "1233ms / 参考 1200ms (HTTP 200)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pageFailed": 0,
|
"pageFailed": 0,
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"GET /api/ops/notifications/?page_size=1",
|
"GET /api/ops/notifications/?page_size=1",
|
||||||
"GET /api/assets/summary/"
|
"GET /api/assets/summary/"
|
||||||
],
|
],
|
||||||
"elapsedMs": 5108,
|
"elapsedMs": 4223,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"GET /api/ai/models/",
|
"GET /api/ai/models/",
|
||||||
"GET /api/ops/notifications/?page_size=1"
|
"GET /api/ops/notifications/?page_size=1"
|
||||||
],
|
],
|
||||||
"elapsedMs": 3716,
|
"elapsedMs": 3760,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
"GET /api/ai/models/",
|
"GET /api/ai/models/",
|
||||||
"GET /api/ops/notifications/?page_size=1"
|
"GET /api/ops/notifications/?page_size=1"
|
||||||
],
|
],
|
||||||
"elapsedMs": 3704,
|
"elapsedMs": 3509,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"GET /api/assets/summary/",
|
"GET /api/assets/summary/",
|
||||||
"GET /api/assets/facets/?meta_keys=gender,age,role&tab=people"
|
"GET /api/assets/facets/?meta_keys=gender,age,role&tab=people"
|
||||||
],
|
],
|
||||||
"elapsedMs": 4733,
|
"elapsedMs": 3494,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
"GET /api/auth/team/members/",
|
"GET /api/auth/team/members/",
|
||||||
"GET /api/billing/ledgers/?page=1&page_size=10"
|
"GET /api/billing/ledgers/?page=1&page_size=10"
|
||||||
],
|
],
|
||||||
"elapsedMs": 4773,
|
"elapsedMs": 3728,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
"GET /api/auth/team/members/",
|
"GET /api/auth/team/members/",
|
||||||
"GET /api/ops/notifications/?page_size=100"
|
"GET /api/ops/notifications/?page_size=100"
|
||||||
],
|
],
|
||||||
"elapsedMs": 5399,
|
"elapsedMs": 3729,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
"GET /api/ops/notifications/?page_size=1",
|
"GET /api/ops/notifications/?page_size=1",
|
||||||
"GET /api/ops/notifications/?page=1&page_size=10"
|
"GET /api/ops/notifications/?page=1&page_size=10"
|
||||||
],
|
],
|
||||||
"elapsedMs": 3717,
|
"elapsedMs": 3693,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
"GET /api/ops/notifications/?page_size=1",
|
"GET /api/ops/notifications/?page_size=1",
|
||||||
"GET /api/assets/?page_size=200&product=045ad8d6-8b15-486b-a4a9-f6968eb1555f"
|
"GET /api/assets/?page_size=200&product=045ad8d6-8b15-486b-a4a9-f6968eb1555f"
|
||||||
],
|
],
|
||||||
"elapsedMs": 4757,
|
"elapsedMs": 4730,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
"GET /api/ai/models/",
|
"GET /api/ai/models/",
|
||||||
"GET /api/ops/notifications/?page_size=1"
|
"GET /api/ops/notifications/?page_size=1"
|
||||||
],
|
],
|
||||||
"elapsedMs": 3725,
|
"elapsedMs": 3737,
|
||||||
"problems": [],
|
"problems": [],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"pass": true
|
"pass": true
|
||||||
|
|||||||
Reference in New Issue
Block a user