生图模型可选(火山/gpt-image)+ 模特上身图提示词强化 + 多会话等改动
本轮(生图模型选择 + 火山接入): - 工作室新增「生图模型」选择器(模特上身图/平台套图头部 chip + 图片创作底部 Pill), 默认火山 Seedream,可切 gpt-image-2;选择写入 localStorage,下次进页面读回 - 后端 resolve_image_model 解析所选模型;enqueue_standalone_images 接 image_model - worker 按模型能力分流:有 image_edit(gpt-image)走多图编辑;无(火山)走 image_generation(image=参考图);新增 _ratio_to_volcano_size 让火山按比例出图 → 内衣等敏感品类用火山可绕开 gpt-image 的 sexual 内容审核 模特上身图提示词: - 穿戴/非穿戴分流、按 index 变化动作场景镜头、负面词尾接、多图参考序号自适应 - _product_reference_urls:商品参考真实上传图优先、排除 AI 生成图、可多张 其他(并入此前各会话未提交改动): - 图片创作多会话(ImageConversation + migration 0019)、任务中心按类型过滤 - accounts/projects/assets/team/auth 等零散调整、相关测试 - 测试脚本(tryon_*.py)、测试清单(core/bug/*.xlsx)
This commit is contained in:
@@ -126,6 +126,65 @@ class InvitationFlowTests(TestCase):
|
|||||||
self.assertEqual(gen2.status_code, 403)
|
self.assertEqual(gen2.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberQuotaAndRoleTests(TestCase):
|
||||||
|
"""PMC#3:① 新建子账号不填限额时默认 100(不再 0=不限,避免子账号无上限花团队池);
|
||||||
|
② login/me 返回当前用户在团队的 role,前端据此只给主账号(owner)显示「团队」「消费」页。"""
|
||||||
|
|
||||||
|
def _register(self, client, username, **extra):
|
||||||
|
return client.post(
|
||||||
|
"/api/auth/register/",
|
||||||
|
{"username": username, "password": "strong-password", **extra},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.owner_client = APIClient()
|
||||||
|
r = self._register(self.owner_client, "q-owner", team_name="Q Team", invite_code=make_create_team_code())
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
self.token = r.data["token"]
|
||||||
|
self.team = Team.objects.get(name="Q Team")
|
||||||
|
self.owner_client.credentials(HTTP_AUTHORIZATION=f"Token {self.token}")
|
||||||
|
|
||||||
|
def test_register_owner_payload_role_is_owner(self):
|
||||||
|
# 注册即登录的返回体带 role=owner(主账号)
|
||||||
|
r = self._register(APIClient(), "solo-owner", team_name="Solo", invite_code=make_create_team_code())
|
||||||
|
self.assertEqual(r.data.get("role"), "owner")
|
||||||
|
|
||||||
|
def test_create_member_defaults_limit_100(self):
|
||||||
|
res = self.owner_client.post(
|
||||||
|
"/api/auth/team/members/",
|
||||||
|
{"username": "sub-a", "password": "strong-password", "role": "member"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 201, res.content)
|
||||||
|
member = TeamMember.objects.get(user__username="sub-a", team=self.team)
|
||||||
|
self.assertEqual(member.monthly_credit_limit, Decimal("100"))
|
||||||
|
|
||||||
|
def test_create_member_explicit_limit_respected(self):
|
||||||
|
res = self.owner_client.post(
|
||||||
|
"/api/auth/team/members/",
|
||||||
|
{"username": "sub-b", "password": "strong-password", "role": "member", "monthly_credit_limit": 800},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 201, res.content)
|
||||||
|
member = TeamMember.objects.get(user__username="sub-b", team=self.team)
|
||||||
|
self.assertEqual(member.monthly_credit_limit, Decimal("800"))
|
||||||
|
|
||||||
|
def test_member_login_returns_member_role(self):
|
||||||
|
self.owner_client.post(
|
||||||
|
"/api/auth/team/members/",
|
||||||
|
{"username": "sub-c", "password": "strong-password", "role": "member"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
login = APIClient().post(
|
||||||
|
"/api/auth/login/",
|
||||||
|
{"username": "sub-c", "password": "strong-password"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(login.status_code, 200)
|
||||||
|
self.assertEqual(login.data.get("role"), "member")
|
||||||
|
|
||||||
|
|
||||||
class PlatformAdminTests(TestCase):
|
class PlatformAdminTests(TestCase):
|
||||||
"""Phase 0:平台超管基础 —— 管理命令 / 权限类 / 登录 me 返回标志且团队为空。"""
|
"""Phase 0:平台超管基础 —— 管理命令 / 权限类 / 登录 me 返回标志且团队为空。"""
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,25 @@ from .serializers import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 子账号默认月度限额(0=不限,只留给主账号/超管)
|
||||||
|
DEFAULT_MEMBER_MONTHLY_LIMIT = 100
|
||||||
|
|
||||||
|
|
||||||
|
def member_role(user, team):
|
||||||
|
"""当前用户在当前团队的成员角色(owner/admin/member);无团队成员关系返回 ""。
|
||||||
|
前端据此决定「团队」「消费」等仅主账号(超管)可见的页面是否展示(PMC#3)。"""
|
||||||
|
if team is None:
|
||||||
|
return ""
|
||||||
|
m = TeamMember.objects.filter(team=team, user=user, status=TeamMember.Status.ACTIVE).first()
|
||||||
|
return m.role if m else ""
|
||||||
|
|
||||||
|
|
||||||
def auth_payload(user, team, token):
|
def auth_payload(user, team, token):
|
||||||
return {
|
return {
|
||||||
"token": token.key,
|
"token": token.key,
|
||||||
"user": UserSerializer(user).data,
|
"user": UserSerializer(user).data,
|
||||||
"team": TeamSerializer(team).data if team is not None else None,
|
"team": TeamSerializer(team).data if team is not None else None,
|
||||||
|
"role": member_role(user, team),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +171,7 @@ def me(request):
|
|||||||
{
|
{
|
||||||
"user": UserSerializer(user).data,
|
"user": UserSerializer(user).data,
|
||||||
"team": TeamSerializer(team).data if team is not None else None,
|
"team": TeamSerializer(team).data if team is not None else None,
|
||||||
|
"role": member_role(user, team),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -276,7 +291,8 @@ def team_members(request):
|
|||||||
team=team,
|
team=team,
|
||||||
user=user,
|
user=user,
|
||||||
role=role,
|
role=role,
|
||||||
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,
|
# 子账号默认月度限额 100(0=不限只留给主账号/超管)—— 避免新成员不设限就无上限地花主账号团队池的钱(PMC#3)。
|
||||||
|
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or DEFAULT_MEMBER_MONTHLY_LIMIT,
|
||||||
)
|
)
|
||||||
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
|
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@@ -299,7 +315,7 @@ def team_invitations(request):
|
|||||||
kind=Invitation.Kind.JOIN_TEAM, # 团管发的码恒为「加入本团队」
|
kind=Invitation.Kind.JOIN_TEAM, # 团管发的码恒为「加入本团队」
|
||||||
role=role,
|
role=role,
|
||||||
email=str(request.data.get("email") or "").strip(),
|
email=str(request.data.get("email") or "").strip(),
|
||||||
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,
|
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or DEFAULT_MEMBER_MONTHLY_LIMIT,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
return Response(InvitationSerializer(invite).data, status=status.HTTP_201_CREATED)
|
return Response(InvitationSerializer(invite).data, status=status.HTTP_201_CREATED)
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-06-27 03:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0005_invitation_kind_alter_invitation_team"),
|
||||||
|
("ai", "0018_default_script_gemini"),
|
||||||
|
("products", "0002_product_products_pr_team_id_ebc2f5_idx"),
|
||||||
|
("projects", "0006_migrate_storyboard_to_shots"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ImageConversation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("title", models.CharField(default="默认创作", max_length=120)),
|
||||||
|
(
|
||||||
|
"mode",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("image", "图片创作"),
|
||||||
|
("model", "模特上身图"),
|
||||||
|
("cover", "平台套图"),
|
||||||
|
],
|
||||||
|
default="image",
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_deleted", models.BooleanField(default=False)),
|
||||||
|
("last_active_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="created_%(class)s_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"product",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="image_conversations",
|
||||||
|
to="products.product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"team",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="%(class)s_set",
|
||||||
|
to="accounts.team",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="aitask",
|
||||||
|
name="conversation",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="tasks",
|
||||||
|
to="ai.imageconversation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="aitask",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["conversation", "created_at"],
|
||||||
|
name="ai_aitask_convers_bbca59_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="imageconversation",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["team", "mode", "-last_active_at"],
|
||||||
|
name="ai_imagecon_team_id_5dd98e_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -53,6 +53,38 @@ class ModelConfig(TimeStampedModel):
|
|||||||
return f"{self.provider.name}:{self.name}:{self.capability}"
|
return f"{self.provider.name}:{self.name}:{self.capability}"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageConversation(TeamOwnedModel):
|
||||||
|
"""图片创作工作室的「对话」实体:一条对话 = 一个生图会话线程,把多次生成(AITask)串起来。
|
||||||
|
左栏会话列表、切换、重命名、历史都基于本表。删除走软删(is_deleted),不连带删图——
|
||||||
|
成图仍在资产库里。mode 区分图片创作 / 模特上身图 / 平台套图三种工作台。"""
|
||||||
|
|
||||||
|
class Mode(models.TextChoices):
|
||||||
|
IMAGE = "image", "图片创作"
|
||||||
|
MODEL = "model", "模特上身图"
|
||||||
|
COVER = "cover", "平台套图"
|
||||||
|
|
||||||
|
title = models.CharField(max_length=120, default="默认创作")
|
||||||
|
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.IMAGE)
|
||||||
|
product = models.ForeignKey(
|
||||||
|
"products.Product",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="image_conversations",
|
||||||
|
)
|
||||||
|
is_deleted = models.BooleanField(default=False)
|
||||||
|
# 每次在该对话里发起生成都刷新,左栏「最近」按它倒序
|
||||||
|
last_active_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["team", "mode", "-last_active_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"conv:{self.mode}:{self.title}"
|
||||||
|
|
||||||
|
|
||||||
class AITask(TeamOwnedModel):
|
class AITask(TeamOwnedModel):
|
||||||
class Type(models.TextChoices):
|
class Type(models.TextChoices):
|
||||||
SCRIPT_GENERATION = "script_generation", "Script Generation"
|
SCRIPT_GENERATION = "script_generation", "Script Generation"
|
||||||
@@ -84,6 +116,14 @@ class AITask(TeamOwnedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name="ai_tasks",
|
related_name="ai_tasks",
|
||||||
)
|
)
|
||||||
|
# 图片创作工作室的对话归属(独立生图任务才挂;流水线内部任务为空)。删对话不删任务。
|
||||||
|
conversation = models.ForeignKey(
|
||||||
|
ImageConversation,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="tasks",
|
||||||
|
)
|
||||||
task_type = models.CharField(max_length=48, choices=Type.choices)
|
task_type = models.CharField(max_length=48, choices=Type.choices)
|
||||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
|
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
|
||||||
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
|
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
|
||||||
@@ -105,6 +145,8 @@ class AITask(TeamOwnedModel):
|
|||||||
models.Index(fields=["provider_task_id"]),
|
models.Index(fields=["provider_task_id"]),
|
||||||
# 任务历史默认按团队 + 创建时间倒序(AI 工具页 / asset-factory)
|
# 任务历史默认按团队 + 创建时间倒序(AI 工具页 / asset-factory)
|
||||||
models.Index(fields=["team", "-created_at"]),
|
models.Index(fields=["team", "-created_at"]),
|
||||||
|
# 按对话拉历史(图片创作工作室切换会话时回填批次)
|
||||||
|
models.Index(fields=["conversation", "created_at"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import AITask, ModelConfig, ModelProvider
|
from .models import AITask, ImageConversation, ModelConfig, ModelProvider
|
||||||
|
|
||||||
|
|
||||||
class ModelProviderSerializer(serializers.ModelSerializer):
|
class ModelProviderSerializer(serializers.ModelSerializer):
|
||||||
@@ -19,8 +19,34 @@ class ModelConfigSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class ImageConversationSerializer(serializers.ModelSerializer):
|
||||||
|
"""图片创作对话:左栏列表用。title 可写(重命名),mode/product 创建时可指定。"""
|
||||||
|
|
||||||
|
task_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ImageConversation
|
||||||
|
fields = ["id", "title", "mode", "product", "task_count", "last_active_at", "created_at", "updated_at"]
|
||||||
|
read_only_fields = ["id", "task_count", "last_active_at", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def get_task_count(self, obj) -> int:
|
||||||
|
# list 接口已 annotate;无 annotate 时回落实时 count(详情/创建场景)
|
||||||
|
cached = getattr(obj, "_task_count", None)
|
||||||
|
return cached if cached is not None else obj.tasks.count()
|
||||||
|
|
||||||
|
|
||||||
class AITaskSerializer(serializers.ModelSerializer):
|
class AITaskSerializer(serializers.ModelSerializer):
|
||||||
model_config = ModelConfigSerializer(read_only=True)
|
model_config = ModelConfigSerializer(read_only=True)
|
||||||
|
# 从 request_payload 抽出的分组/标签信息(由 AITaskViewSet annotate 提供;其它调用处无此注解则为 None)。
|
||||||
|
# 不直接读 obj.request_payload —— 那列已 defer,读了会触发懒加载把几 MB payload 整列拉回。
|
||||||
|
batch_id = serializers.SerializerMethodField()
|
||||||
|
mode = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_batch_id(self, obj):
|
||||||
|
return getattr(obj, "rp_batch_id", None)
|
||||||
|
|
||||||
|
def get_mode(self, obj):
|
||||||
|
return getattr(obj, "rp_mode", None)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AITask
|
model = AITask
|
||||||
@@ -28,6 +54,8 @@ class AITaskSerializer(serializers.ModelSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"project",
|
"project",
|
||||||
"task_type",
|
"task_type",
|
||||||
|
"batch_id",
|
||||||
|
"mode",
|
||||||
"status",
|
"status",
|
||||||
"model_config",
|
"model_config",
|
||||||
"provider_task_id",
|
"provider_task_id",
|
||||||
@@ -40,5 +68,6 @@ class AITaskSerializer(serializers.ModelSerializer):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
# batch_id / mode 是显式声明的 SerializerMethodField(本就只读),不能再列进 read_only_fields(DRF 会报错)
|
||||||
|
read_only_fields = [f for f in fields if f not in ("batch_id", "mode")]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -760,6 +761,33 @@ def _ratio_to_image_size(ratio: str) -> str:
|
|||||||
}.get((ratio or "").strip(), "1024x1024")
|
}.get((ratio or "").strip(), "1024x1024")
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio_to_volcano_size(ratio: str) -> str:
|
||||||
|
"""前端比例 → 火山 Seedream 尺寸(~2K 面积,各边夹在 [1024,4096] 且取 16 的倍数)。
|
||||||
|
预设比例直接给好尺寸;自定义 W:H 按 2K 面积换算;解析不到回落 '2K'。"""
|
||||||
|
presets = {
|
||||||
|
"1:1": "2048x2048",
|
||||||
|
"3:4": "1728x2304",
|
||||||
|
"4:3": "2304x1728",
|
||||||
|
"9:16": "1440x2560",
|
||||||
|
"16:9": "2560x1440",
|
||||||
|
"4:5": "1664x2080",
|
||||||
|
}
|
||||||
|
r = (ratio or "").strip()
|
||||||
|
if r in presets:
|
||||||
|
return presets[r]
|
||||||
|
if ":" in r:
|
||||||
|
try:
|
||||||
|
w_str, h_str = r.split(":", 1)
|
||||||
|
w, h = float(w_str), float(h_str)
|
||||||
|
if w > 0 and h > 0:
|
||||||
|
scale = math.sqrt((2048 * 2048) / (w * h))
|
||||||
|
side = lambda v: min(max(int(round(v * scale / 16) * 16), 1024), 4096) # noqa: E731
|
||||||
|
return f"{side(w)}x{side(h)}"
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
pass
|
||||||
|
return "2K"
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -953,6 +981,32 @@ def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str
|
|||||||
return " ".join(lines)
|
return " ".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_free_reference_prompt(base_prompt: str, n_refs: int = 1) -> str:
|
||||||
|
"""图片创作自由模式 · 带用户上传参考图时的提示词。
|
||||||
|
纯把用户原话丢给图生图,模型只会松散借个色调、不会真的保留参考图里的主体(背心/商品/人物),
|
||||||
|
这正是「没参考我上传的素材」的根因。这里显式把参考图钉成「画面主体的唯一依据」,要求严格保留其
|
||||||
|
外形/款式/配色/材质/品牌文字/图案,再在此基础上按用户要求创作。"""
|
||||||
|
base = (base_prompt or "").strip()
|
||||||
|
if n_refs <= 1:
|
||||||
|
ref_intro = "参考图是用户提供的素材,是本次画面主体(商品 / 人物 / 物体)的唯一依据。"
|
||||||
|
ref_word = "参考图"
|
||||||
|
else:
|
||||||
|
rng = f"1-{n_refs}" if n_refs > 2 else "1、2"
|
||||||
|
ref_intro = f"参考图{rng}是用户提供的素材(同一主体的不同角度 / 多个主体),是本次画面主体的唯一依据。"
|
||||||
|
ref_word = f"参考图{rng}"
|
||||||
|
lines = [
|
||||||
|
ref_intro,
|
||||||
|
f"请严格保留{ref_word}中主体的外形、款式、配色、材质、纹样、品牌文字与 Logo,"
|
||||||
|
"不要重新设计、不要换款、不要生成相似但不同的物体;主体须与参考图高度一致。",
|
||||||
|
]
|
||||||
|
if base:
|
||||||
|
lines.append(f"在此基础上,按用户要求创作:{base}")
|
||||||
|
else:
|
||||||
|
lines.append("在此基础上,生成干净、专业的电商视觉画面。")
|
||||||
|
lines.append("画面真实、构图协调、细节清晰。")
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def build_person_frontal_prompt(description: str = "") -> str:
|
def build_person_frontal_prompt(description: str = "") -> str:
|
||||||
"""人物正面氛围图提示词:把脚本提取(或用户输入)的人物描述包成统一模板。
|
"""人物正面氛围图提示词:把脚本提取(或用户输入)的人物描述包成统一模板。
|
||||||
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
|
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
|
||||||
@@ -1998,7 +2052,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, image_model: 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, conversation=None, reference_image_ids: list[str] | None = None) -> list[AITask]:
|
||||||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||||||
|
|
||||||
@@ -2014,6 +2068,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
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)
|
||||||
count = max(1, min(int(count or 1), 12))
|
count = max(1, min(int(count or 1), 12))
|
||||||
|
ref_ids = [str(r) for r in (reference_image_ids or []) if r]
|
||||||
# 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。
|
# 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。
|
||||||
batch_id = str(uuid.uuid4())
|
batch_id = str(uuid.uuid4())
|
||||||
tasks: list[AITask] = []
|
tasks: list[AITask] = []
|
||||||
@@ -2023,11 +2078,12 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
team=team,
|
team=team,
|
||||||
created_by=user,
|
created_by=user,
|
||||||
project=None,
|
project=None,
|
||||||
|
conversation=conversation,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
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), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None},
|
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, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids},
|
||||||
estimated_cost=cost,
|
estimated_cost=cost,
|
||||||
)
|
)
|
||||||
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
||||||
@@ -2082,6 +2138,14 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
product_url = _product_cover_url(product) if product is not None else ""
|
product_url = _product_cover_url(product) if product is not None else ""
|
||||||
# 模特上身图:真实上传图优先、排除 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 []
|
||||||
|
# 图片创作自由模式:用户上传的参考图(已先传成 Asset)→ 取直链,作多图参考(image_edit / 图生图)
|
||||||
|
ref_urls: list[str] = []
|
||||||
|
for rid in (payload.get("reference_image_ids") or []):
|
||||||
|
ref_asset = Asset.objects.filter(id=rid).first()
|
||||||
|
if ref_asset is not None:
|
||||||
|
u = _asset_preview_url(ref_asset)
|
||||||
|
if u:
|
||||||
|
ref_urls.append(u)
|
||||||
|
|
||||||
# 参考图收集与 provider 无关:先把「该用哪些参考图 + 哪条提示词」定下来,再按模型能力选调用方式。
|
# 参考图收集与 provider 无关:先把「该用哪些参考图 + 哪条提示词」定下来,再按模型能力选调用方式。
|
||||||
edit_images: list[str] = []
|
edit_images: list[str] = []
|
||||||
@@ -2100,6 +2164,11 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
elif bool(payload.get("reference_product")) 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, "")
|
||||||
|
elif ref_urls:
|
||||||
|
# 图片创作自由模式:以用户上传的参考图为基底出图。提示词必须显式要求「保留参考图主体」,
|
||||||
|
# 否则图生图只会松散借个色调、不真的还原上传的素材(=用户反馈的「没参考我的图」)。
|
||||||
|
edit_images = ref_urls
|
||||||
|
edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls))
|
||||||
use_edit = bool(edit_images)
|
use_edit = bool(edit_images)
|
||||||
try:
|
try:
|
||||||
if use_edit and can_edit:
|
if use_edit and can_edit:
|
||||||
@@ -2110,8 +2179,10 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
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:
|
elif use_edit:
|
||||||
# 火山 Seedream 无 image_edit:走 image_generation 带 image=参考图(图生图多参考),size 用 2K
|
# 火山 Seedream 无 image_edit:走 image_generation 带 image=参考图(图生图多参考);
|
||||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size="2K")
|
# 尺寸按选中比例换算成火山可接受的 ~2K 尺寸(三视图固定横向)
|
||||||
|
vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(str(payload.get("ratio") or ""))
|
||||||
|
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size=vsize)
|
||||||
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)
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.accounts.models import Team, User
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.accounts.models import Team, TeamMember, User
|
||||||
|
from apps.ai.models import AITask, ImageConversation, ModelConfig
|
||||||
from apps.ai.providers.volcano import VolcanoArkProvider
|
from apps.ai.providers.volcano import VolcanoArkProvider
|
||||||
from apps.ai.services import enqueue_standalone_images
|
from apps.ai.services import enqueue_standalone_images
|
||||||
from apps.assets.models import Asset, AssetFile
|
from apps.assets.models import Asset, AssetFile
|
||||||
@@ -245,6 +248,116 @@ class StandaloneImageReferenceTests(TestCase):
|
|||||||
prov.image_generation.assert_called_once()
|
prov.image_generation.assert_called_once()
|
||||||
prov.image_edit.assert_not_called()
|
prov.image_edit.assert_not_called()
|
||||||
|
|
||||||
|
def test_image_mode_uses_uploaded_reference_images(self):
|
||||||
|
"""图片创作自由模式:用户上传的参考图必须作为 image_edit 的参考图传进去,
|
||||||
|
而不是被忽略走纯文生图——回归保护 #图片创作没参考我上传的素材# 这个最致命的 bug。"""
|
||||||
|
prov = self._patch_provider()
|
||||||
|
ref = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="背心参考", asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.UPLOAD, category=Asset.Category.UPLOAD,
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(asset=ref, object_key="r.png", bucket="b", content_type="image/png", preview_url="http://x/ref.png", is_primary=True)
|
||||||
|
enqueue_standalone_images(
|
||||||
|
team=self.team, user=self.user, prompt="按这件背心生成不同场景的穿搭",
|
||||||
|
mode="image", count=1, ratio="1:1", reference_image_ids=[str(ref.id)],
|
||||||
|
)
|
||||||
|
prov.image_edit.assert_called_once()
|
||||||
|
self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/ref.png"])
|
||||||
|
# 提示词必须显式钉住「保留参考图主体」+ 带上用户原话(否则图生图不真的还原上传素材)
|
||||||
|
used_prompt = prov.image_edit.call_args.kwargs["prompt"]
|
||||||
|
self.assertIn("按这件背心生成不同场景的穿搭", used_prompt)
|
||||||
|
self.assertIn("参考图", used_prompt)
|
||||||
|
self.assertIn("严格保留", used_prompt)
|
||||||
|
prov.image_generation.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class ImageConversationTests(TestCase):
|
||||||
|
"""图片创作「对话」实体:CRUD + 团队隔离 + 软删 + 生图自动归属对话 + 任务回填。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="convowner", password="pass")
|
||||||
|
self.team = Team.objects.create(name="ConvT", owner=self.user)
|
||||||
|
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
def test_crud_and_listing(self):
|
||||||
|
# 新建
|
||||||
|
r = self.client.post("/api/ai/image-conversations/", {"mode": "image", "title": "测试创作"}, format="json")
|
||||||
|
self.assertEqual(r.status_code, 201, r.content)
|
||||||
|
conv_id = r.json()["id"]
|
||||||
|
# 列出(只 image 模式)
|
||||||
|
r = self.client.get("/api/ai/image-conversations/?mode=image")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(len(r.json()["results"]), 1)
|
||||||
|
# 重命名
|
||||||
|
r = self.client.patch(f"/api/ai/image-conversations/{conv_id}/", {"title": "改后"}, format="json")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()["title"], "改后")
|
||||||
|
# 软删:列表消失,但 DB 记录仍在(is_deleted=True)
|
||||||
|
r = self.client.delete(f"/api/ai/image-conversations/{conv_id}/")
|
||||||
|
self.assertIn(r.status_code, (204, 200))
|
||||||
|
self.assertEqual(self.client.get("/api/ai/image-conversations/?mode=image").json()["results"], [])
|
||||||
|
self.assertTrue(ImageConversation.objects.get(id=conv_id).is_deleted)
|
||||||
|
|
||||||
|
def test_team_isolation(self):
|
||||||
|
other = User.objects.create_user(username="other", password="pass")
|
||||||
|
other_team = Team.objects.create(name="Other", owner=other)
|
||||||
|
ImageConversation.objects.create(team=other_team, created_by=other, title="别人的")
|
||||||
|
r = self.client.get("/api/ai/image-conversations/?mode=image")
|
||||||
|
self.assertEqual(r.json()["results"], [])
|
||||||
|
|
||||||
|
def test_generate_auto_creates_and_binds_conversation(self):
|
||||||
|
# 余额 + 默认图像模型(迁移已 seed),provider/worker 全 mock,聚焦验证「任务挂到对话」
|
||||||
|
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||||
|
patch("apps.ai.tasks.generate_standalone_image_task.delay").start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="目标对话")
|
||||||
|
tasks = enqueue_standalone_images(team=self.team, user=self.user, prompt="一只猫", mode="image", count=2, conversation=conv)
|
||||||
|
self.assertEqual(len(tasks), 2)
|
||||||
|
self.assertTrue(all(t.conversation_id == conv.id for t in tasks))
|
||||||
|
self.assertEqual(conv.tasks.count(), 2)
|
||||||
|
|
||||||
|
def test_tasks_endpoint_returns_grouped_history(self):
|
||||||
|
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="历史")
|
||||||
|
mc = ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE).first()
|
||||||
|
AITask.objects.create(
|
||||||
|
team=self.team, created_by=self.user, conversation=conv, task_type=AITask.Type.PRODUCT_IMAGE,
|
||||||
|
status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="conv-test-1",
|
||||||
|
request_payload={"prompt": "猫", "batch_id": "bx", "ratio": "1:1"},
|
||||||
|
)
|
||||||
|
r = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/")
|
||||||
|
self.assertEqual(r.status_code, 200, r.content)
|
||||||
|
data = r.json()["tasks"]
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
self.assertEqual(data[0]["prompt"], "猫")
|
||||||
|
self.assertEqual(data[0]["batch_id"], "bx")
|
||||||
|
|
||||||
|
def test_generate_with_refs_persists_and_tasks_endpoint_returns_them(self):
|
||||||
|
"""HTTP 全链路:带 reference_image_ids 提交 → 任务 payload 落 ids → tasks 接口把参考图解析回 {name,url}。
|
||||||
|
守护「上传的参考图被带进生成 + 切换/刷新后批次头仍能回显参考图」。"""
|
||||||
|
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||||
|
patch("apps.ai.tasks.generate_standalone_image_task.delay").start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
ref = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="背心参考", asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.UPLOAD, category=Asset.Category.UPLOAD,
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(asset=ref, object_key="r.png", bucket="b", content_type="image/png", preview_url="http://x/ref.png", is_primary=True)
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/ai/generate-image/",
|
||||||
|
{"prompt": "按这件背心生成不同场景", "mode": "image", "count": 1, "reference_image_ids": [str(ref.id)]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 202, r.content)
|
||||||
|
conv_id = r.json()["conversation_id"]
|
||||||
|
# 任务 payload 落了参考图 id
|
||||||
|
task = AITask.objects.filter(conversation_id=conv_id).first()
|
||||||
|
self.assertEqual(task.request_payload.get("reference_image_ids"), [str(ref.id)])
|
||||||
|
# tasks 接口把参考图解析成 {name,url} 回显
|
||||||
|
data = self.client.get(f"/api/ai/image-conversations/{conv_id}/tasks/").json()["tasks"]
|
||||||
|
self.assertEqual(data[0]["reference_images"], [{"name": "背心参考", "url": "http://x/ref.png"}])
|
||||||
|
|
||||||
|
|
||||||
class StandaloneCategoryTests(TestCase):
|
class StandaloneCategoryTests(TestCase):
|
||||||
"""图片趴三类归类(期2):模特上身图→model_tryon / 平台套图→platform_kit / 自由创作→free_create;
|
"""图片趴三类归类(期2):模特上身图→model_tryon / 平台套图→platform_kit / 自由创作→free_create;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .views import AITaskViewSet, GenerateImageView, ModelConfigViewSet
|
from .views import AITaskViewSet, GenerateImageView, ImageConversationViewSet, ModelConfigViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register("tasks", AITaskViewSet, basename="ai-task")
|
router.register("tasks", AITaskViewSet, basename="ai-task")
|
||||||
router.register("models", ModelConfigViewSet, basename="model-config")
|
router.register("models", ModelConfigViewSet, basename="model-config")
|
||||||
|
router.register("image-conversations", ImageConversationViewSet, basename="image-conversation")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
from django.db.models import Count
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||||
|
|
||||||
from apps.assets.serializers import AssetSerializer
|
from apps.assets.serializers import AssetSerializer
|
||||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||||
from apps.common.celery_health import require_worker
|
from apps.common.celery_health import require_worker
|
||||||
|
|
||||||
from .models import AITask, ModelConfig
|
from .models import AITask, ImageConversation, ModelConfig
|
||||||
from .serializers import AITaskSerializer, ModelConfigSerializer
|
from .serializers import AITaskSerializer, ImageConversationSerializer, ModelConfigSerializer
|
||||||
from .services import enqueue_standalone_images
|
from .services import enqueue_standalone_images
|
||||||
|
|
||||||
|
|
||||||
@@ -35,13 +38,33 @@ class GenerateImageView(APIView):
|
|||||||
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
|
image_model = str(request.data.get("image_model") or "").strip() or None
|
||||||
|
conversation_id = str(request.data.get("conversation_id") or "").strip() or None
|
||||||
|
# 用户在图片创作里上传的参考图(已先传成 Asset),按 id 列表带入 → 生成时作多图参考(image_edit)
|
||||||
|
raw_refs = request.data.get("reference_image_ids") or []
|
||||||
|
if isinstance(raw_refs, str):
|
||||||
|
raw_refs = [s for s in raw_refs.split(",") if s.strip()]
|
||||||
|
reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()]
|
||||||
team = get_current_team(request.user)
|
team = get_current_team(request.user)
|
||||||
|
# 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。
|
||||||
|
conversation = None
|
||||||
|
if conversation_id:
|
||||||
|
conversation = ImageConversation.objects.filter(team=team, id=conversation_id, is_deleted=False).first()
|
||||||
|
if conversation is None:
|
||||||
|
conversation = ImageConversation.objects.create(
|
||||||
|
team=team,
|
||||||
|
created_by=request.user,
|
||||||
|
mode=mode if mode in dict(ImageConversation.Mode.choices) else ImageConversation.Mode.IMAGE,
|
||||||
|
product_id=product_id,
|
||||||
|
title=(prompt[:24] or "默认创作"),
|
||||||
|
)
|
||||||
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, image_model=image_model)
|
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, conversation=conversation, reference_image_ids=reference_image_ids)
|
||||||
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)
|
||||||
|
# 本次提交即刷新对话活跃时间,左栏「最近」据此置顶
|
||||||
|
ImageConversation.objects.filter(id=conversation.id).update(last_active_at=timezone.now())
|
||||||
return Response(
|
return Response(
|
||||||
{"tasks": [{"id": str(t.id), "status": t.status} for t in tasks]},
|
{"conversation_id": str(conversation.id), "tasks": [{"id": str(t.id), "status": t.status} for t in tasks]},
|
||||||
status=status.HTTP_202_ACCEPTED,
|
status=status.HTTP_202_ACCEPTED,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,6 +105,13 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
|||||||
# 可选 ?task_type=a,b,c 过滤:生图工作室的任务中心只想看生图任务(模特上身图/平台套图/
|
# 可选 ?task_type=a,b,c 过滤:生图工作室的任务中心只想看生图任务(模特上身图/平台套图/
|
||||||
# 图片创作 = person_image / product_image),不掺脚本/实体抽取/故事板等流水线内部任务。
|
# 图片创作 = person_image / product_image),不掺脚本/实体抽取/故事板等流水线内部任务。
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
# 从 request_payload(已 defer)里只抽 batch_id / mode 两个 JSON 标量供前端「按批分组 + 标签」用:
|
||||||
|
# KeyTextTransform 在 SQL 层 JSON_EXTRACT,不会把几 MB 的 payload 整列拉回(避开 payload 性能坑)。
|
||||||
|
from django.db.models.fields.json import KeyTextTransform
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
rp_batch_id=KeyTextTransform("batch_id", "request_payload"),
|
||||||
|
rp_mode=KeyTextTransform("mode", "request_payload"),
|
||||||
|
)
|
||||||
raw = self.request.query_params.get("task_type", "").strip()
|
raw = self.request.query_params.get("task_type", "").strip()
|
||||||
if raw:
|
if raw:
|
||||||
types = [t.strip() for t in raw.split(",") if t.strip()]
|
types = [t.strip() for t in raw.split(",") if t.strip()]
|
||||||
@@ -90,6 +120,73 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||||
|
"""图片创作工作室的「对话」CRUD。
|
||||||
|
list 按 ?mode= 过滤、排除软删、按 last_active_at 倒序(左栏「最近」);
|
||||||
|
create 开新对话;partial_update 重命名;destroy 软删(不连带删图)。
|
||||||
|
detail action `tasks` 返回该对话下的生图任务 + 成图 asset,供切换对话时回填批次流。
|
||||||
|
"""
|
||||||
|
|
||||||
|
serializer_class = ImageConversationSerializer
|
||||||
|
queryset = ImageConversation.objects.filter(is_deleted=False).select_related("product").order_by("-last_active_at")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
|
||||||
|
mode = self.request.query_params.get("mode", "").strip()
|
||||||
|
if mode:
|
||||||
|
queryset = queryset.filter(mode=mode)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
# 软删:对话从列表消失,但其 AITask.conversation 置空由 DB on_delete=SET_NULL 不触发(我们没真删),
|
||||||
|
# 成图始终留在资产库。仅打标记。
|
||||||
|
instance.is_deleted = True
|
||||||
|
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def tasks(self, request, pk=None):
|
||||||
|
from apps.assets.models import Asset
|
||||||
|
from apps.assets.serializers import _asset_preview
|
||||||
|
|
||||||
|
conversation = self.get_object()
|
||||||
|
tasks = (
|
||||||
|
AITask.objects.filter(conversation=conversation)
|
||||||
|
.prefetch_related("generated_assets", "generated_assets__files")
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
|
# 参考图 id → {name,url}:跨任务可能重复,缓存一次解析,供切换/刷新后批次头回显「参考了哪些图」
|
||||||
|
ref_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def resolve_refs(ids):
|
||||||
|
out = []
|
||||||
|
for rid in ids or []:
|
||||||
|
rid = str(rid)
|
||||||
|
if rid not in ref_cache:
|
||||||
|
a = Asset.objects.filter(id=rid).prefetch_related("files").first()
|
||||||
|
ref_cache[rid] = {"name": a.name, "url": _asset_preview(a)} if a else None
|
||||||
|
if ref_cache[rid]:
|
||||||
|
out.append(ref_cache[rid])
|
||||||
|
return out
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": str(t.id),
|
||||||
|
"status": t.status,
|
||||||
|
"error_message": t.error_message,
|
||||||
|
"prompt": (t.request_payload or {}).get("prompt", ""),
|
||||||
|
"batch_id": (t.request_payload or {}).get("batch_id", ""),
|
||||||
|
"ratio": (t.request_payload or {}).get("ratio") or "",
|
||||||
|
"reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")),
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"assets": AssetSerializer(
|
||||||
|
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||||
|
).data,
|
||||||
|
}
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
return Response({"conversation_id": str(conversation.id), "tasks": data})
|
||||||
|
|
||||||
|
|
||||||
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||||
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
|
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
|
||||||
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
|
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
|
||||||
|
|||||||
@@ -104,11 +104,18 @@ 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
|
||||||
|
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
|
||||||
|
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
|
||||||
|
# 任务中心要看「全部生成图」(含未入库),传 ?in_library=all 旁路;?in_library=false 只看未入库。
|
||||||
|
if self.action in ("list", "batches"):
|
||||||
|
lib = (p.get("in_library") or "").lower()
|
||||||
|
if lib == "all":
|
||||||
|
pass
|
||||||
|
elif lib in ("false", "0"):
|
||||||
|
qs = qs.filter(in_library=False)
|
||||||
|
else:
|
||||||
|
qs = qs.filter(in_library=True)
|
||||||
if p.get("tab"):
|
if p.get("tab"):
|
||||||
qs = qs.filter(_tab_q(p["tab"]))
|
qs = qs.filter(_tab_q(p["tab"]))
|
||||||
if p.get("category"):
|
if p.get("category"):
|
||||||
@@ -117,6 +124,8 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
qs = qs.filter(asset_type=p["asset_type"])
|
qs = qs.filter(asset_type=p["asset_type"])
|
||||||
if p.get("source"):
|
if p.get("source"):
|
||||||
qs = qs.filter(source=p["source"])
|
qs = qs.filter(source=p["source"])
|
||||||
|
if p.get("origin_task"):
|
||||||
|
qs = qs.filter(origin_task_id=p["origin_task"])
|
||||||
if p.get("product"):
|
if p.get("product"):
|
||||||
# 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product;
|
# 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product;
|
||||||
# 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。
|
# 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。
|
||||||
|
|||||||
@@ -872,3 +872,82 @@ class BaseAssetAdoptDeleteTests(TestCase):
|
|||||||
res = self.client.post(f"/api/projects/{self.proj.id}/delete-base-asset/", {"group_id": str(self.group.id)}, format="json")
|
res = self.client.post(f"/api/projects/{self.proj.id}/delete-base-asset/", {"group_id": str(self.group.id)}, format="json")
|
||||||
self.assertEqual(res.status_code, 200)
|
self.assertEqual(res.status_code, 200)
|
||||||
self.assertFalse(BaseAssetGroup.objects.filter(id=self.group.id).exists())
|
self.assertFalse(BaseAssetGroup.objects.filter(id=self.group.id).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class AttachOfficialModelTests(TestCase):
|
||||||
|
"""ZWQ#4/#5 回归:从演员库挑「官方模特」(跨团队)新增/替换角色,旧版因 team 隔离报 asset not found
|
||||||
|
且替换失败、还留下空角色组。修复后:官方模特形象图自动克隆进本团队并真正挂上,不再 404。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.assets.models import Asset, AssetFile, Model
|
||||||
|
from apps.projects.models import BaseAssetGroup
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(username="biz", password="x")
|
||||||
|
self.team = Team.objects.create(name="商家团队", owner=self.user)
|
||||||
|
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||||
|
self.product = Product.objects.create(team=self.team, created_by=self.user, title="口红")
|
||||||
|
self.proj = Project.objects.create(team=self.team, name="带货视频", product=self.product, created_by=self.user)
|
||||||
|
|
||||||
|
# 官方模特属于「官方团队」(跨团队可见,但 portrait 资产不属本商家团队)——正是触发 bug 的根因。
|
||||||
|
self.official_owner = User.objects.create_user(username="ops", password="x")
|
||||||
|
self.official_team = Team.objects.create(name="官方", owner=self.official_owner)
|
||||||
|
self.portrait = Asset.objects.create(
|
||||||
|
team=self.official_team, created_by=self.official_owner, name="都市白领 林晚",
|
||||||
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||||
|
metadata={"kind": "model"},
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(asset=self.portrait, object_key="m/linwan.png", bucket="b", content_type="image/png", preview_url="http://x/linwan.png", is_primary=True)
|
||||||
|
self.model = Model.objects.create(
|
||||||
|
team=self.official_team, created_by=self.official_owner, name="都市白领 林晚",
|
||||||
|
is_official=True, portrait_asset=self.portrait,
|
||||||
|
)
|
||||||
|
self.BaseAssetGroup = BaseAssetGroup
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
def test_add_actor_from_official_model_clones_and_attaches(self):
|
||||||
|
"""新增角色:无 group,传 kind+label,选官方模特 → 200,克隆进本团队并采用,不再 asset not found。"""
|
||||||
|
from apps.assets.models import Asset
|
||||||
|
|
||||||
|
res = self.client.post(
|
||||||
|
f"/api/projects/{self.proj.id}/attach-base-asset/",
|
||||||
|
{"kind": "person", "label": "林晚", "asset_id": str(self.portrait.id)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 200, res.content)
|
||||||
|
group = self.BaseAssetGroup.objects.get(id=res.json()["id"])
|
||||||
|
self.assertIsNotNone(group.adopted_asset_id)
|
||||||
|
clone = Asset.objects.get(id=group.adopted_asset_id)
|
||||||
|
# 克隆体真正落进本商家团队(不是官方团队),并共享同一份 TOS 对象。
|
||||||
|
self.assertEqual(clone.team_id, self.team.id)
|
||||||
|
self.assertNotEqual(clone.id, self.portrait.id)
|
||||||
|
self.assertEqual(clone.files.first().object_key, "m/linwan.png")
|
||||||
|
self.assertEqual(clone.metadata.get("cloned_from_asset"), str(self.portrait.id))
|
||||||
|
|
||||||
|
def test_replace_existing_group_with_official_model(self):
|
||||||
|
"""替换:已有角色组,传 group_id + 官方模特 asset_id → 200,采用克隆体。"""
|
||||||
|
from apps.assets.models import Asset
|
||||||
|
|
||||||
|
group = self.BaseAssetGroup.objects.create(project=self.proj, kind=self.BaseAssetGroup.Kind.PERSON, metadata={"label": "主播"})
|
||||||
|
res = self.client.post(
|
||||||
|
f"/api/projects/{self.proj.id}/attach-base-asset/",
|
||||||
|
{"group_id": str(group.id), "asset_id": str(self.portrait.id)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 200, res.content)
|
||||||
|
group.refresh_from_db()
|
||||||
|
clone = Asset.objects.get(id=group.adopted_asset_id)
|
||||||
|
self.assertEqual(clone.team_id, self.team.id)
|
||||||
|
|
||||||
|
def test_unknown_asset_still_404_without_ghost_group(self):
|
||||||
|
"""彻底不存在的 asset_id → 仍 404,且不留下空角色组(不再在 404 前提交建组)。"""
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
before = self.BaseAssetGroup.objects.filter(project=self.proj).count()
|
||||||
|
res = self.client.post(
|
||||||
|
f"/api/projects/{self.proj.id}/attach-base-asset/",
|
||||||
|
{"kind": "person", "label": "幽灵", "asset_id": str(_uuid.uuid4())},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
self.assertEqual(self.BaseAssetGroup.objects.filter(project=self.proj).count(), before)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count, Q
|
||||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@@ -105,6 +105,39 @@ def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str,
|
|||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_asset_into_team(src: Asset, *, team, user) -> Asset:
|
||||||
|
"""把跨团队可见的「官方模特」形象图克隆一份进目标团队:共享同一份 TOS 对象(只新建 Asset/AssetFile DB 行,
|
||||||
|
不重新上传),使其成为本团队真实资产 —— 可被基础资产组采用、在「我的演员」里列出,不再因 team 隔离报 asset not found。
|
||||||
|
去掉 metadata 里的 kind=model 标记,让克隆体作为普通「我的演员」person 资产,而不是被误判成模特库预设。"""
|
||||||
|
src_meta = {k: v for k, v in (src.metadata or {}).items() if k != "kind"}
|
||||||
|
clone = Asset.objects.create(
|
||||||
|
team=team,
|
||||||
|
created_by=user,
|
||||||
|
name=src.name,
|
||||||
|
asset_type=src.asset_type,
|
||||||
|
source=src.source,
|
||||||
|
category=src.category,
|
||||||
|
description=src.description,
|
||||||
|
metadata={**src_meta, "cloned_from_asset": str(src.id), "from_official_model": True},
|
||||||
|
in_library=src.in_library,
|
||||||
|
)
|
||||||
|
for f in src.files.all():
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=clone,
|
||||||
|
object_key=f.object_key,
|
||||||
|
bucket=f.bucket,
|
||||||
|
content_type=f.content_type,
|
||||||
|
size_bytes=f.size_bytes,
|
||||||
|
checksum=f.checksum,
|
||||||
|
width=f.width,
|
||||||
|
height=f.height,
|
||||||
|
duration_ms=f.duration_ms,
|
||||||
|
preview_url=f.preview_url,
|
||||||
|
is_primary=f.is_primary,
|
||||||
|
)
|
||||||
|
return clone
|
||||||
|
|
||||||
|
|
||||||
def promote_base_asset_stage_if_ready(project: Project) -> bool:
|
def promote_base_asset_stage_if_ready(project: Project) -> bool:
|
||||||
adopted_kind_count = (
|
adopted_kind_count = (
|
||||||
project.base_asset_groups.filter(adopted_asset__isnull=False).values("kind").distinct().count()
|
project.base_asset_groups.filter(adopted_asset__isnull=False).values("kind").distinct().count()
|
||||||
@@ -492,8 +525,26 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def attach_base_asset(self, request, pk=None):
|
def attach_base_asset(self, request, pk=None):
|
||||||
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。
|
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。
|
||||||
seed 占位卡还没有 group,此时不传 group_id,改传 kind+label:按 label 命中同实体组,没有则据 label 建组再挂(不出图)。"""
|
seed 占位卡还没有 group,此时不传 group_id,改传 kind+label:按 label 命中同实体组,没有则据 label 建组再挂(不出图)。
|
||||||
|
所选资产可能是「官方模特」形象图(跨团队可见但不属本团队)→ 克隆一份进本团队再挂:避免 team 隔离导致
|
||||||
|
明明选了官方模特却报 asset not found、替换失败。"""
|
||||||
project = self.get_object()
|
project = self.get_object()
|
||||||
|
# 先把要挂的资产定下来(本团队资产,或官方模特形象图克隆),拿不到立即 404 —— 必须在建/找组「之前」,
|
||||||
|
# 否则会留下一个采用资产为空的幽灵角色组(旧版本在 404 return 前就 create 了组,且 @atomic 正常 return 仍会提交)。
|
||||||
|
asset_id = request.data.get("asset_id")
|
||||||
|
asset = Asset.objects.filter(team=project.team, id=asset_id).first()
|
||||||
|
if asset is None and asset_id:
|
||||||
|
# 跨团队可见的官方模特形象图 / 三视图 → 克隆进本团队再挂
|
||||||
|
official = (
|
||||||
|
Asset.objects.filter(id=asset_id, is_deleted=False)
|
||||||
|
.filter(Q(model_as_portrait__is_official=True) | Q(model_as_triview__is_official=True))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if official is not None:
|
||||||
|
asset = _clone_asset_into_team(official, team=project.team, user=request.user)
|
||||||
|
if asset is None:
|
||||||
|
return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
group = None
|
group = None
|
||||||
group_id = request.data.get("group_id")
|
group_id = request.data.get("group_id")
|
||||||
if group_id:
|
if group_id:
|
||||||
@@ -517,9 +568,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
group_meta = {"label": label} if label else {}
|
group_meta = {"label": label} if label else {}
|
||||||
group = BaseAssetGroup.objects.create(project=project, kind=kind, metadata=group_meta)
|
group = BaseAssetGroup.objects.create(project=project, kind=kind, metadata=group_meta)
|
||||||
group = BaseAssetGroup.objects.select_for_update().get(id=group.id)
|
group = BaseAssetGroup.objects.select_for_update().get(id=group.id)
|
||||||
asset = Asset.objects.filter(team=project.team, id=request.data.get("asset_id")).first()
|
|
||||||
if asset is None:
|
|
||||||
return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
group.candidate_assets.add(asset)
|
group.candidate_assets.add(asset)
|
||||||
group.adopted_asset_id = asset.id
|
group.adopted_asset_id = asset.id
|
||||||
group.save(update_fields=["adopted_asset", "updated_at"])
|
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||||
|
|||||||
Binary file not shown.
+33
-10
@@ -103,6 +103,9 @@ export function App() {
|
|||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [team, setTeam] = useState<Team | null>(null);
|
const [team, setTeam] = useState<Team | null>(null);
|
||||||
|
// 当前用户在该团队的角色(owner/admin/member)。主账号(owner=超管)才看得到「团队」「消费」页(PMC#3)。
|
||||||
|
const [role, setRole] = useState<string>("");
|
||||||
|
const isOwner = role === "owner";
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [productTotal, setProductTotal] = useState(0); // 后端真实总数(分页 count),侧栏/仪表盘徽标用
|
const [productTotal, setProductTotal] = useState(0); // 后端真实总数(分页 count),侧栏/仪表盘徽标用
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@@ -232,6 +235,7 @@ export function App() {
|
|||||||
if (cancelled || !identity) return;
|
if (cancelled || !identity) return;
|
||||||
setUser(identity.user);
|
setUser(identity.user);
|
||||||
setTeam(identity.team);
|
setTeam(identity.team);
|
||||||
|
setRole(identity.role || "");
|
||||||
} catch (bootError) {
|
} catch (bootError) {
|
||||||
// 仅「身份校验失败」才登出;me() 成功后的数据加载失败不在此处
|
// 仅「身份校验失败」才登出;me() 成功后的数据加载失败不在此处
|
||||||
console.error("[boot] identity failed:", bootError);
|
console.error("[boot] identity failed:", bootError);
|
||||||
@@ -280,6 +284,16 @@ export function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [booting, user, team, route.admin]);
|
}, [booting, user, team, route.admin]);
|
||||||
|
|
||||||
|
// 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回工作台(PMC#3)。
|
||||||
|
// role 为空时(身份尚未带回角色)不拦,避免误纠超管。
|
||||||
|
useEffect(() => {
|
||||||
|
if (booting || !user || !role) return;
|
||||||
|
if (!isOwner && (page === "team" || page === "account")) {
|
||||||
|
navigate("dashboard", { replace: true });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [booting, user, role, isOwner, page]);
|
||||||
|
|
||||||
// Load preferences + sessions when entering settings.
|
// Load preferences + sessions when entering settings.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
||||||
@@ -487,19 +501,21 @@ 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; image_model?: 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; conversation_id?: string; reference_image_ids?: string[] }) {
|
||||||
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
||||||
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
||||||
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
||||||
return action(async () => {
|
return action(async () => {
|
||||||
const { tasks } = await api.submitGenerateImage(payload);
|
const { tasks, conversation_id } = await api.submitGenerateImage(payload);
|
||||||
const ids = tasks.map((t) => t.id);
|
const ids = tasks.map((t) => t.id);
|
||||||
if (ids.length === 0) throw new Error("未能提交生成任务");
|
if (ids.length === 0) throw new Error("未能提交生成任务");
|
||||||
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
|
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
|
||||||
// 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名)
|
// 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名)
|
||||||
const batchProduct = products.find((p) => p.id === payload.product_id);
|
const batchProduct = products.find((p) => p.id === payload.product_id);
|
||||||
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count, productId: payload.product_id, productTitle: batchProduct?.title });
|
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count, productId: payload.product_id, productTitle: batchProduct?.title });
|
||||||
return pollImageTasks(payload.mode, ids);
|
const res = await pollImageTasks(payload.mode, ids);
|
||||||
|
// 回传后端归属/新建的对话 id,供工作室把它登记进左栏会话列表并设为 active
|
||||||
|
return { ...res, conversation_id };
|
||||||
}, "图片已生成");
|
}, "图片已生成");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,23 +595,29 @@ export function App() {
|
|||||||
return assets[0].id;
|
return assets[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAuthed(payload: { token: string; user: User; team: Team; remember?: boolean }) {
|
async function onAuthed(payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) {
|
||||||
setToken(payload.token, payload.remember ?? true);
|
setToken(payload.token, payload.remember ?? true);
|
||||||
setUser(payload.user);
|
setUser(payload.user);
|
||||||
setTeam(payload.team);
|
setTeam(payload.team);
|
||||||
|
setRole(payload.role || "");
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
setAuthed(true);
|
|
||||||
// 平台超管且无团队:直落后台,不拉团队级数据(否则 products/projects 等接口因无团队报错)
|
// 平台超管且无团队:直落后台,不拉团队级数据(否则 products/projects 等接口因无团队报错)
|
||||||
if (payload.user.is_platform_admin && !payload.team) {
|
if (payload.user.is_platform_admin && !payload.team) {
|
||||||
|
setAuthed(true);
|
||||||
navigateAdmin("", { replace: true });
|
navigateAdmin("", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate("dashboard", { replace: true });
|
navigate("dashboard", { replace: true });
|
||||||
// 首登水合:与 boot 路径一致地重试,失败不再静默(否则页面卡在全 0,要刷新才好)
|
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
|
||||||
loadDataWithRetry().catch((error) => {
|
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
|
||||||
|
try {
|
||||||
|
await loadDataWithRetry();
|
||||||
|
} catch (error) {
|
||||||
console.error("[login] data hydrate failed:", error);
|
console.error("[login] data hydrate failed:", error);
|
||||||
setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" });
|
setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" });
|
||||||
});
|
} finally {
|
||||||
|
setAuthed(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
@@ -604,6 +626,7 @@ export function App() {
|
|||||||
setAuthed(false);
|
setAuthed(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setTeam(null);
|
setTeam(null);
|
||||||
|
setRole("");
|
||||||
setAuthMode("login");
|
setAuthMode("login");
|
||||||
window.history.replaceState(null, "", "/login");
|
window.history.replaceState(null, "", "/login");
|
||||||
}
|
}
|
||||||
@@ -643,7 +666,7 @@ export function App() {
|
|||||||
<div className="content">
|
<div className="content">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>加载中…</h1>
|
<h1>正在进入工作台…</h1>
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
<span className="mono">// 正在拉取团队数据</span>
|
<span className="mono">// 正在拉取团队数据</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -992,7 +1015,7 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} logout={logout} onOpenAdmin={() => navigateAdmin("")} />
|
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} logout={logout} onOpenAdmin={() => navigateAdmin("")} />
|
||||||
<main>
|
<main>
|
||||||
<Decorations />
|
<Decorations />
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
|
|||||||
@@ -971,6 +971,41 @@
|
|||||||
font-family: var(--font-mono); font-size: 12px;
|
font-family: var(--font-mono); font-size: 12px;
|
||||||
letter-spacing: .02em; display: inline-block; margin-top: 4px;
|
letter-spacing: .02em; display: inline-block; margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
/* 对话操作失败的内联提示(不再静默吞错) */
|
||||||
|
.image-workbench .ic-conv-error {
|
||||||
|
margin: 6px 12px 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px; line-height: 1.5;
|
||||||
|
color: var(--heat);
|
||||||
|
background: var(--heat-12);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
/* 会话项就地重命名输入框 */
|
||||||
|
.image-workbench .ic-conv-rename {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
font-size: 13px; font-family: inherit;
|
||||||
|
color: var(--accent-black);
|
||||||
|
background: var(--accent-white);
|
||||||
|
border: 1px solid var(--heat-20);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 2px 6px; outline: none;
|
||||||
|
}
|
||||||
|
/* 会话项 hover 露出的「重命名 / 删除」 */
|
||||||
|
.image-workbench .ic-conv-acts {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: none; align-items: center; gap: 2px;
|
||||||
|
}
|
||||||
|
.image-workbench .ic-conv-item:hover .ic-conv-acts { display: inline-flex; }
|
||||||
|
.image-workbench .ic-conv-acts button {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
transition: background var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.image-workbench .ic-conv-acts button:hover { background: var(--background-base); color: var(--heat); }
|
||||||
|
.image-workbench .ic-conv-acts svg { width: 12px; height: 12px; }
|
||||||
|
|
||||||
/* 右 · 对话流主体 */
|
/* 右 · 对话流主体 */
|
||||||
.image-workbench .ic-main {
|
.image-workbench .ic-main {
|
||||||
@@ -1064,6 +1099,20 @@
|
|||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
.image-workbench .ic-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }
|
.image-workbench .ic-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }
|
||||||
|
/* 批次头:本批用过的参考图缩略 */
|
||||||
|
.image-workbench .ic-msg-prompt .pt-refs {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||||
|
}
|
||||||
|
.image-workbench .ic-msg-prompt .pt-ref {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: var(--r-sm); overflow: hidden;
|
||||||
|
border: 1px solid var(--border-faint); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.image-workbench .ic-msg-prompt .pt-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.image-workbench .ic-msg-prompt .pt-ref-label {
|
||||||
|
font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
/* 底部 · chat 输入栏 */
|
/* 底部 · chat 输入栏 */
|
||||||
.image-workbench .ic-input-wrap {
|
.image-workbench .ic-input-wrap {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import type {
|
|||||||
Ledger,
|
Ledger,
|
||||||
LoginSession,
|
LoginSession,
|
||||||
Invitation,
|
Invitation,
|
||||||
|
ImageConversation,
|
||||||
|
ImageConversationTask,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
ModelEntity,
|
ModelEntity,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -135,7 +137,7 @@ export const api = {
|
|||||||
return request<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
|
return request<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
me() {
|
me() {
|
||||||
return request<{ user: User; team: Team }>("/api/auth/me/");
|
return request<{ user: User; team: Team; role?: string }>("/api/auth/me/");
|
||||||
},
|
},
|
||||||
updateProfile(payload: { name?: string; phone?: string; email?: string }) {
|
updateProfile(payload: { name?: string; phone?: string; email?: string }) {
|
||||||
return request<{ user: User; team: Team }>("/api/auth/me/", { method: "PATCH", body: JSON.stringify(payload) });
|
return request<{ user: User; team: Team }>("/api/auth/me/", { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
@@ -456,7 +458,9 @@ export const api = {
|
|||||||
// 服务端分页+过滤的资产列表(各页按需懒加载,不再前端取全量再切片)。
|
// 服务端分页+过滤的资产列表(各页按需懒加载,不再前端取全量再切片)。
|
||||||
assetsPage(params: {
|
assetsPage(params: {
|
||||||
tab?: string; category?: string; source?: string; asset_type?: string;
|
tab?: string; category?: string; source?: string; asset_type?: string;
|
||||||
product?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
|
product?: string; origin_task?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
|
||||||
|
// in_library: "all" 看全部(含未入库,任务中心用)/ "false" 只看未入库 / 省略=只看已入库
|
||||||
|
in_library?: string;
|
||||||
meta?: Record<string, string>;
|
meta?: Record<string, string>;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
@@ -585,9 +589,27 @@ export const api = {
|
|||||||
// 以便 全部/已完成/失败 三个 tab 数按真实总量算。
|
// 以便 全部/已完成/失败 三个 tab 数按真实总量算。
|
||||||
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200&task_type=person_image,product_image");
|
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200&task_type=person_image,product_image");
|
||||||
},
|
},
|
||||||
// 异步生图:提交后秒级返回任务列表(慢出图在 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; image_model?: string }) {
|
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
|
||||||
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
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; conversation_id?: string; reference_image_ids?: string[] }) {
|
||||||
|
return request<{ conversation_id: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
|
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
|
||||||
|
listConversations(mode: "image" | "model" | "cover" = "image") {
|
||||||
|
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/?mode=${mode}&page_size=100`);
|
||||||
|
},
|
||||||
|
createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) {
|
||||||
|
return request<ImageConversation>("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
|
renameConversation(id: string, title: string) {
|
||||||
|
return request<ImageConversation>(`/api/ai/image-conversations/${id}/`, { method: "PATCH", body: JSON.stringify({ title }) });
|
||||||
|
},
|
||||||
|
deleteConversation(id: string) {
|
||||||
|
return request<void>(`/api/ai/image-conversations/${id}/`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
// 切换对话时回填:该对话历次生成的任务 + 成图
|
||||||
|
conversationTasks(id: string) {
|
||||||
|
return request<{ conversation_id: string; tasks: ImageConversationTask[] }>(`/api/ai/image-conversations/${id}/tasks/`);
|
||||||
},
|
},
|
||||||
generateImageStatus(ids: string[]) {
|
generateImageStatus(ids: string[]) {
|
||||||
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
|
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
|
||||||
|
|||||||
@@ -29,14 +29,17 @@ const SHELL_COMMANDS: Command[] = [
|
|||||||
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑、加入资产库", page: "imageOptimize", icon: "images" }
|
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑、加入资产库", page: "imageOptimize", icon: "images" }
|
||||||
];
|
];
|
||||||
|
|
||||||
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
|
function CommandPalette({ open, onClose, navigate, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
useEffect(() => { if (open) setQuery(""); }, [open]);
|
useEffect(() => { if (open) setQuery(""); }, [open]);
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return SHELL_COMMANDS.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
|
// 非主账号:命令面板里也不暴露「团队」「消费」(与侧栏一致,PMC#3)
|
||||||
}, [query]);
|
return SHELL_COMMANDS
|
||||||
|
.filter((cmd) => canManageBilling || (cmd.page !== "team" && cmd.page !== "account"))
|
||||||
|
.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
|
||||||
|
}, [query, canManageBilling]);
|
||||||
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
|
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
let lastGroup = "";
|
let lastGroup = "";
|
||||||
@@ -109,13 +112,14 @@ const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [
|
|||||||
{ act: "account", icon: "creditCard", label: "消费与余额" }
|
{ act: "account", icon: "creditCard", label: "消费与余额" }
|
||||||
];
|
];
|
||||||
|
|
||||||
function AccountMenu({ anchorRect, onClose, navigate, logout, user, team }: {
|
function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
|
||||||
anchorRect: DOMRect;
|
anchorRect: DOMRect;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
navigate: Navigate;
|
navigate: Navigate;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
user: User;
|
user: User;
|
||||||
team: Team | null;
|
team: Team | null;
|
||||||
|
canManageBilling?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 });
|
const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 });
|
||||||
@@ -169,7 +173,7 @@ function AccountMenu({ anchorRect, onClose, navigate, logout, user, team }: {
|
|||||||
<span className="mail">{user.username}</span>
|
<span className="mail">{user.username}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ACCOUNT_ITEMS.map((item) => (
|
{ACCOUNT_ITEMS.filter((item) => canManageBilling || (item.act !== "team" && item.act !== "account")).map((item) => (
|
||||||
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
|
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
|
||||||
<IconKitSvg name={item.icon} />
|
<IconKitSvg name={item.icon} />
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -222,11 +226,13 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
|||||||
settingsNotify: "settings"
|
settingsNotify: "settings"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal, logout, onOpenAdmin }: {
|
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, logout, onOpenAdmin }: {
|
||||||
page: Page;
|
page: Page;
|
||||||
navigate: Navigate;
|
navigate: Navigate;
|
||||||
user: User;
|
user: User;
|
||||||
team: Team | null;
|
team: Team | null;
|
||||||
|
// 主账号(owner=超管)才看得到「团队」「消费」入口;子账号隐藏(PMC#3)。默认 true 兼容旧调用。
|
||||||
|
canManageBilling?: boolean;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
productTotal?: number;
|
productTotal?: number;
|
||||||
@@ -238,6 +244,8 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
|||||||
onOpenAdmin?: () => void;
|
onOpenAdmin?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const activeNav = PAGE_TO_NAV[page];
|
const activeNav = PAGE_TO_NAV[page];
|
||||||
|
// 子账号:导航里去掉「团队」「消费」两项(命令面板、账户菜单同步过滤)。
|
||||||
|
const navItems = NAV.filter((item) => canManageBilling || (item.page !== "team" && item.page !== "account"));
|
||||||
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
||||||
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
|
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
|
||||||
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
||||||
@@ -320,7 +328,7 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
|||||||
</div>
|
</div>
|
||||||
<div className="nav-section">主要</div>
|
<div className="nav-section">主要</div>
|
||||||
<nav>
|
<nav>
|
||||||
{NAV.map((item) => (
|
{navItems.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={`/${item.id}`}
|
href={`/${item.id}`}
|
||||||
@@ -367,7 +375,7 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
|
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} canManageBilling={canManageBilling} />
|
||||||
{accountAnchor && (
|
{accountAnchor && (
|
||||||
<AccountMenu
|
<AccountMenu
|
||||||
anchorRect={accountAnchor}
|
anchorRect={accountAnchor}
|
||||||
@@ -376,6 +384,7 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
|||||||
logout={handleLogout}
|
logout={handleLogout}
|
||||||
user={user}
|
user={user}
|
||||||
team={team}
|
team={team}
|
||||||
|
canManageBilling={canManageBilling}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Quote,
|
Quote,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
WandSparkles,
|
WandSparkles,
|
||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AITask, Asset, ModelConfig, ModelEntity, Product } from "../types";
|
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product } from "../types";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { ActorLibrary } from "../components/actor-library";
|
import { ActorLibrary } from "../components/actor-library";
|
||||||
import { SkeletonRows } from "../components/loading";
|
import { SkeletonRows } from "../components/loading";
|
||||||
@@ -33,13 +34,12 @@ import type { Page } from "./route-config";
|
|||||||
import { statusPill } from "./stage-config";
|
import { statusPill } from "./stage-config";
|
||||||
import "../ai-tools-page.css";
|
import "../ai-tools-page.css";
|
||||||
|
|
||||||
const TASK_TYPE_LABEL: Record<string, string> = {
|
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
|
||||||
|
// 而 task_type 区分不了——cover 与 image 都是 product_image)
|
||||||
|
const MODE_LABEL: Record<string, string> = {
|
||||||
model: "模特上身图",
|
model: "模特上身图",
|
||||||
platform: "平台套图",
|
cover: "平台套图",
|
||||||
image: "图片创作",
|
image: "图片创作"
|
||||||
model_photo: "模特上身图",
|
|
||||||
platform_cover: "平台套图",
|
|
||||||
image_optimize: "图片创作"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
@@ -94,7 +94,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
setTasksLoading(true);
|
setTasksLoading(true);
|
||||||
api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}).finally(() => { if (alive) setTasksLoading(false); });
|
api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}).finally(() => { if (alive) setTasksLoading(false); });
|
||||||
api.assetsPage({ asset_type: "image", source: "ai_generated", pageSize: 200 })
|
// 任务中心 = 生成历史:要看全部生成图(含未加入资产库的),故 in_library: "all" 旁路过滤
|
||||||
|
api.assetsPage({ asset_type: "image", source: "ai_generated", in_library: "all", pageSize: 200 })
|
||||||
.then((res) => { if (alive) setAssets(res.results); })
|
.then((res) => { if (alive) setAssets(res.results); })
|
||||||
.catch(() => { if (alive) setAssets([]); });
|
.catch(() => { if (alive) setAssets([]); });
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
@@ -133,16 +134,44 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 任务中心只看「工作台图片生成」(mode∈model/cover/image —— 能分模特上身图/平台套图/图片创作;
|
||||||
|
// 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。
|
||||||
|
// 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。
|
||||||
|
type TaskBatch = {
|
||||||
|
key: string; batchId: string | null; firstTaskId: string; label: string; mode: string;
|
||||||
|
count: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
|
||||||
|
};
|
||||||
|
const taskBatches = useMemo<TaskBatch[]>(() => {
|
||||||
|
const groups = new Map<string, AITask[]>();
|
||||||
|
for (const t of aiTasks) {
|
||||||
|
if (!t.mode || !MODE_LABEL[t.mode]) continue; // 只保留图片生成模式(model/cover/image)
|
||||||
|
const key = t.batch_id || t.id;
|
||||||
|
const arr = groups.get(key);
|
||||||
|
if (arr) arr.push(t);
|
||||||
|
else groups.set(key, [t]);
|
||||||
|
}
|
||||||
|
const out: TaskBatch[] = [];
|
||||||
|
for (const [key, tasks] of groups) {
|
||||||
|
const first = tasks[0];
|
||||||
|
const pills = tasks.map((t) => statusPill(t.status));
|
||||||
|
const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err";
|
||||||
|
const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), "");
|
||||||
|
const cover = tasks.map((t) => taskImage[t.id]).find(Boolean) || "";
|
||||||
|
out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!], mode: first.mode!, count: tasks.length, status, created_at: created, cover });
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||||
|
return out;
|
||||||
|
}, [aiTasks, taskImage]);
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const acc = { gen: 0, ok: 0, err: 0 };
|
const acc = { gen: 0, ok: 0, err: 0 };
|
||||||
for (const task of aiTasks) {
|
for (const b of taskBatches) {
|
||||||
const pill = statusPill(task.status);
|
if (b.status === "ok") acc.ok += 1;
|
||||||
if (pill === "ok") acc.ok += 1;
|
else if (b.status === "err") acc.err += 1;
|
||||||
else if (pill === "err") acc.err += 1;
|
else acc.gen += 1;
|
||||||
else if (pill === "info") acc.gen += 1;
|
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, [aiTasks]);
|
}, [taskBatches]);
|
||||||
|
|
||||||
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
|
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
|
||||||
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
|
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
|
||||||
@@ -154,6 +183,25 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
|
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
|
||||||
const TASKS_PER_PAGE = 10;
|
const TASKS_PER_PAGE = 10;
|
||||||
const [taskPage, setTaskPage] = useState(1);
|
const [taskPage, setTaskPage] = useState(1);
|
||||||
|
// 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大
|
||||||
|
const [openBatch, setOpenBatch] = useState<TaskBatch | null>(null);
|
||||||
|
const [batchImgs, setBatchImgs] = useState<Asset[]>([]);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openBatch) { setBatchImgs([]); return; }
|
||||||
|
let alive = true;
|
||||||
|
setBatchLoading(true);
|
||||||
|
// 有 batch_id 按批取整批;旧图无 batch_id 则按 origin_task 取该任务那张
|
||||||
|
const params = openBatch.batchId
|
||||||
|
? { meta: { batch_id: openBatch.batchId }, in_library: "all", asset_type: "image", pageSize: 50 }
|
||||||
|
: { origin_task: openBatch.firstTaskId, in_library: "all", asset_type: "image", pageSize: 50 };
|
||||||
|
api.assetsPage(params)
|
||||||
|
.then((res) => { if (alive) setBatchImgs(res.results); })
|
||||||
|
.catch(() => { if (alive) setBatchImgs([]); })
|
||||||
|
.finally(() => { if (alive) setBatchLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [openBatch]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openChip) return;
|
if (!openChip) return;
|
||||||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
|
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
|
||||||
@@ -161,22 +209,21 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
return () => document.removeEventListener("click", close);
|
return () => document.removeEventListener("click", close);
|
||||||
}, [openChip]);
|
}, [openChip]);
|
||||||
|
|
||||||
const typeOptions = Array.from(new Set(aiTasks.map((t) => t.task_type).filter(Boolean)));
|
const typeOptions = Array.from(new Set(taskBatches.map((b) => b.label).filter(Boolean)));
|
||||||
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
|
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
|
||||||
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
|
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
|
||||||
];
|
];
|
||||||
const visible = aiTasks.filter((task) => {
|
const visible = taskBatches.filter((batch) => {
|
||||||
const pill = statusPill(task.status);
|
if (filter === "gen" && batch.status !== "info") return false;
|
||||||
if (filter === "gen" && pill !== "info") return false;
|
if (filter === "ok" && batch.status !== "ok") return false;
|
||||||
if (filter === "ok" && pill !== "ok") return false;
|
if (filter === "err" && batch.status !== "err") return false;
|
||||||
if (filter === "err" && pill !== "err") return false;
|
if (typeFilter && batch.label !== typeFilter) return false;
|
||||||
if (typeFilter && task.task_type !== typeFilter) return false;
|
if (timeFilter !== "all" && batch.created_at) {
|
||||||
if (timeFilter !== "all" && task.created_at) {
|
const days = (Date.now() - new Date(batch.created_at).getTime()) / 86400000;
|
||||||
const days = (Date.now() - new Date(task.created_at).getTime()) / 86400000;
|
|
||||||
if (days > Number(timeFilter)) return false;
|
if (days > Number(timeFilter)) return false;
|
||||||
}
|
}
|
||||||
if (query) {
|
if (query) {
|
||||||
const hay = `${TASK_TYPE_LABEL[task.task_type] || task.task_type} ${task.task_type} ${task.id}`.toLowerCase();
|
const hay = `${batch.label} ${batch.key}`.toLowerCase();
|
||||||
if (!hay.includes(query.toLowerCase())) return false;
|
if (!hay.includes(query.toLowerCase())) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -228,13 +275,13 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
<div className="section-h">
|
<div className="section-h">
|
||||||
<h2>任务中心</h2>
|
<h2>任务中心</h2>
|
||||||
<span className="sub-mono">
|
<span className="sub-mono">
|
||||||
// {aiTasks.length} 个 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
|
// {taskBatches.length} 批 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
|
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
|
||||||
<div className="tabs" id="tc-tabs">
|
<div className="tabs" id="tc-tabs">
|
||||||
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}>全部 <span className="count">{aiTasks.length}</span></div>
|
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}>全部 <span className="count">{taskBatches.length}</span></div>
|
||||||
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}>生成中 <span className="count">{counts.gen}</span></div>
|
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}>生成中 <span className="count">{counts.gen}</span></div>
|
||||||
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}>已完成 <span className="count">{counts.ok}</span></div>
|
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}>已完成 <span className="count">{counts.ok}</span></div>
|
||||||
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}>失败 <span className="count">{counts.err}</span></div>
|
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}>失败 <span className="count">{counts.err}</span></div>
|
||||||
@@ -259,7 +306,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
</div>
|
</div>
|
||||||
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
|
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
|
||||||
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
||||||
<span className="chip-label">{typeFilter ? TASK_TYPE_LABEL[typeFilter] || typeFilter : "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
<span className="chip-label">{typeFilter || "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="chip-menu">
|
<div className="chip-menu">
|
||||||
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
|
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
|
||||||
@@ -268,7 +315,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
{typeOptions.length > 0 && <div className="mi-sep" />}
|
{typeOptions.length > 0 && <div className="mi-sep" />}
|
||||||
{typeOptions.map((t) => (
|
{typeOptions.map((t) => (
|
||||||
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
|
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
|
||||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{TASK_TYPE_LABEL[t] || t}
|
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{t}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +340,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="result-meta">
|
<div className="result-meta">
|
||||||
// 显示 {paged.length} / {visible.length} 个任务
|
// 显示 {paged.length} / {visible.length} 批
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasksLoading && aiTasks.length === 0 ? (
|
{tasksLoading && aiTasks.length === 0 ? (
|
||||||
@@ -310,19 +357,19 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
</div>
|
</div>
|
||||||
) : view === "grid" ? (
|
) : view === "grid" ? (
|
||||||
<div className="history-grid">
|
<div className="history-grid">
|
||||||
{paged.map((task) => {
|
{paged.map((batch) => {
|
||||||
const pill = statusPill(task.status);
|
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
|
||||||
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
|
||||||
const img = taskImage[task.id];
|
|
||||||
return (
|
return (
|
||||||
<article className="task-card history-card" key={task.id}>
|
<article className="task-card history-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
|
||||||
<div className={`placeholder${img ? " has-img" : ""}`}>{img ? <img src={img} alt={typeLabel} loading="lazy" decoding="async" /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}</div>
|
onClick={() => setOpenBatch(batch)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
|
||||||
|
<div className={`placeholder${batch.cover ? " has-img" : ""}`}>{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label}</span>}</div>
|
||||||
<div className="history-body">
|
<div className="history-body">
|
||||||
<div className="history-name">{typeLabel}</div>
|
<div className="history-name">{batch.label}</div>
|
||||||
<div className="history-type">// {task.task_type}</div>
|
<div className="history-type">// {batch.count} 张</div>
|
||||||
<div className="history-foot">
|
<div className="history-foot">
|
||||||
<span className="mono">{(task.created_at || "").slice(0, 10)}</span>
|
<span className="mono">{(batch.created_at || "").slice(0, 10)}</span>
|
||||||
<span className={`pill ${pill}`}><span className="dot" />{statusText(task.status)}</span>
|
<span className={`pill ${batch.status}`}><span className="dot" />{statusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -342,44 +389,44 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.map((task) => {
|
{paged.map((batch) => {
|
||||||
const pill = statusPill(task.status);
|
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
|
||||||
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
|
||||||
const img = taskImage[task.id];
|
|
||||||
return (
|
return (
|
||||||
<tr key={task.id}>
|
<tr key={batch.key} role="button" tabIndex={0} title="查看本批图片" style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => setOpenBatch(batch)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
|
||||||
<td>
|
<td>
|
||||||
<div className="task-name-cell">
|
<div className="task-name-cell">
|
||||||
<div className={`placeholder task-thumb${img ? " has-img" : ""}`}>
|
<div className={`placeholder task-thumb${batch.cover ? " has-img" : ""}`}>
|
||||||
{img ? <img src={img} alt={typeLabel} loading="lazy" decoding="async" /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}
|
{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label.slice(0, 2)}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="task-name">{typeLabel}</div>
|
<div className="task-name">{batch.label}</div>
|
||||||
<div className="task-sub">// {task.task_type}</div>
|
<div className="task-sub">// {batch.count} 张</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{pill === "info" ? (
|
{batch.status === "info" ? (
|
||||||
<div className="task-list-prog">
|
<div className="task-list-prog">
|
||||||
<div className="bar">
|
<div className="bar">
|
||||||
<span style={{ width: "60%" }} />
|
<span style={{ width: "60%" }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="pct">60%</span>
|
<span className="pct">生成中</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted-2 mono" style={{ fontSize: 12 }}>
|
<span className="muted-2 mono" style={{ fontSize: 12 }}>
|
||||||
{pill === "ok" ? "已完成" : "—"}
|
{batch.status === "ok" ? "已完成" : "—"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`pill ${pill}`}>
|
<span className={`pill ${batch.status}`}>
|
||||||
<span className="dot" />
|
<span className="dot" />
|
||||||
{statusText(task.status)}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="muted-2 mono" style={{ fontSize: 12 }}>{(task.created_at || "").slice(0, 10)}</td>
|
<td className="muted-2 mono" style={{ fontSize: 12 }}>{(batch.created_at || "").slice(0, 10)}</td>
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -390,6 +437,46 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
|
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
|
||||||
|
|
||||||
|
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
|
||||||
|
{openBatch && createPortal(
|
||||||
|
<div className="modal-bg show" onClick={() => setOpenBatch(null)}>
|
||||||
|
<div className="modal with-corners" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 760, width: "92%" }}>
|
||||||
|
<span className="corner-tr" aria-hidden />
|
||||||
|
<span className="corner-bl" aria-hidden />
|
||||||
|
<div className="modal-h">
|
||||||
|
<div className="ic-m"><LayoutGrid size={17} /></div>
|
||||||
|
<div className="ti">{openBatch.label}<span>// {openBatch.count} 张 · {(openBatch.created_at || "").slice(0, 10)}</span></div>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-b">
|
||||||
|
{batchLoading ? (
|
||||||
|
<div className="task-empty"><div className="mono">// LOADING…</div></div>
|
||||||
|
) : batchImgs.length === 0 ? (
|
||||||
|
<div className="task-empty"><div className="mono">// NO IMAGE</div><div>这批没有可显示的图片</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="gen-images" style={{ "--cols": Math.min(4, batchImgs.length), "--ratio": "1 / 1" } as React.CSSProperties}>
|
||||||
|
{batchImgs.map((a, i) => {
|
||||||
|
const u = a.files?.find((f) => f.preview_url)?.preview_url || a.files?.[0]?.preview_url || "";
|
||||||
|
return (
|
||||||
|
<div className="gen-image" key={a.id}>
|
||||||
|
{u ? (
|
||||||
|
<img className="gen-image-img" src={u} alt={a.name} loading="lazy" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: u, name: a.name })} />
|
||||||
|
) : (
|
||||||
|
<div className="placeholder"><span className="ph-frame">#{i + 1}</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -450,16 +537,6 @@ const IMAGE_SUGGESTIONS = [
|
|||||||
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
|
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
|
||||||
];
|
];
|
||||||
|
|
||||||
/* 图片创作 · 风格胶囊(基线 image-optimize STYLES) */
|
|
||||||
const STYLE_OPTIONS = [
|
|
||||||
{ id: "auto", label: "默认" },
|
|
||||||
{ id: "realistic", label: "写实" },
|
|
||||||
{ id: "cinematic", label: "电影感" },
|
|
||||||
{ id: "anime", label: "动漫" },
|
|
||||||
{ id: "oil", label: "油画" },
|
|
||||||
{ id: "cn-ink", label: "国风水墨" }
|
|
||||||
];
|
|
||||||
|
|
||||||
/* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */
|
/* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */
|
||||||
const FALLBACK_MODELS = [
|
const FALLBACK_MODELS = [
|
||||||
{ id: "m1", name: "Ava", tag: "亚洲·25岁·清新" },
|
{ id: "m1", name: "Ava", tag: "亚洲·25岁·清新" },
|
||||||
@@ -503,6 +580,8 @@ type GenBatch = {
|
|||||||
modelName?: string;
|
modelName?: string;
|
||||||
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
||||||
platformIds?: string[];
|
platformIds?: string[];
|
||||||
|
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
|
||||||
|
refs?: { name: string; url: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImageWorkbenchPage({
|
export function ImageWorkbenchPage({
|
||||||
@@ -521,7 +600,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; image_model?: 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; conversation_id?: string; reference_image_ids?: string[] }) => Promise<{ assets: Asset[]; conversation_id?: string } | 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,13 +612,13 @@ export function ImageWorkbenchPage({
|
|||||||
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
||||||
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
|
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
|
||||||
const product = products.find((item) => item.id === productId) || products[0];
|
const product = products.find((item) => item.id === productId) || products[0];
|
||||||
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
|
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
|
||||||
|
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
|
||||||
const [ratio, setRatio] = useState(meta.ratio);
|
const [ratio, setRatio] = useState(meta.ratio);
|
||||||
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
|
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
|
||||||
const [ratioManual, setRatioManual] = useState(false);
|
const [ratioManual, setRatioManual] = useState(false);
|
||||||
const [ratioW, setRatioW] = useState("");
|
const [ratioW, setRatioW] = useState("");
|
||||||
const [ratioH, setRatioH] = useState("");
|
const [ratioH, setRatioH] = useState("");
|
||||||
const [style, setStyle] = useState("auto");
|
|
||||||
// 生图模型选择:默认火山(内衣等敏感品类不被审核拦,还原也更好),可切 gpt-image-2。持久化到 localStorage。
|
// 生图模型选择:默认火山(内衣等敏感品类不被审核拦,还原也更好),可切 gpt-image-2。持久化到 localStorage。
|
||||||
const [genModel, setGenModel] = useState<string>(() => {
|
const [genModel, setGenModel] = useState<string>(() => {
|
||||||
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
|
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
|
||||||
@@ -564,7 +643,21 @@ export function ImageWorkbenchPage({
|
|||||||
const [openMore, setOpenMore] = useState("");
|
const [openMore, setOpenMore] = useState("");
|
||||||
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
|
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
|
||||||
const [batches, setBatches] = useState<GenBatch[]>([]);
|
const [batches, setBatches] = useState<GenBatch[]>([]);
|
||||||
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
|
/* ── 图片创作「对话」(真实体,后端 ImageConversation)──
|
||||||
|
conversations = 左栏会话列表;activeConvId = 当前选中对话(空 = 还没建,首次发送时后端自动开)。
|
||||||
|
activeConvRef 让 startBatch 在不进 deps 的情况下读到最新 active id。 */
|
||||||
|
const [conversations, setConversations] = useState<ImageConversation[]>([]);
|
||||||
|
const [activeConvId, setActiveConvId] = useState<string>("");
|
||||||
|
const activeConvRef = useRef<string>("");
|
||||||
|
useEffect(() => { activeConvRef.current = activeConvId; }, [activeConvId]);
|
||||||
|
const [convLoading, setConvLoading] = useState(false);
|
||||||
|
// 对话操作失败的可见提示(替代原来的静默吞错)
|
||||||
|
const [convError, setConvError] = useState("");
|
||||||
|
// 重命名:正在改名的对话 id + 草稿
|
||||||
|
const [renamingId, setRenamingId] = useState("");
|
||||||
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
|
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
|
||||||
|
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
|
||||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
// 生成结果图片放大预览
|
// 生成结果图片放大预览
|
||||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
@@ -589,9 +682,10 @@ export function ImageWorkbenchPage({
|
|||||||
return p.cover_preview_url || primary?.preview_url || "";
|
return p.cover_preview_url || primary?.preview_url || "";
|
||||||
};
|
};
|
||||||
function pickReference(event: ChangeEvent<HTMLInputElement>) {
|
function pickReference(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const files = Array.from(event.target.files || []);
|
||||||
if (!file) return;
|
if (!files.length) return;
|
||||||
setRefImage({ name: file.name, url: URL.createObjectURL(file) });
|
// 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传
|
||||||
|
setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,8 +721,10 @@ export function ImageWorkbenchPage({
|
|||||||
useEffect(() => { loadModels(); }, [loadModels]);
|
useEffect(() => { loadModels(); }, [loadModels]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product) setPrompt(meta.promptTemplate(product.title));
|
// image 模式默认留空(不预填模板);模特/平台仍随商品预填模板
|
||||||
// mode 或商品切换都重置 prompt 与默认比例
|
if (mode === "image") setPrompt("");
|
||||||
|
else if (product) setPrompt(meta.promptTemplate(product.title));
|
||||||
|
// mode 或商品切换都重置默认比例
|
||||||
setRatio(meta.ratio);
|
setRatio(meta.ratio);
|
||||||
setRatioManual(false);
|
setRatioManual(false);
|
||||||
setRatioW("");
|
setRatioW("");
|
||||||
@@ -682,6 +778,133 @@ export function ImageWorkbenchPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════ 图片创作「对话」实体的增删改查 + 历史回填 ════════ */
|
||||||
|
|
||||||
|
// 后端对话任务流 → 前端批次:按 batch_id 把同一次提交的多张图归到一个 GenBatch
|
||||||
|
function batchesFromConvTasks(tasks: ImageConversationTask[]): GenBatch[] {
|
||||||
|
const groups = new Map<string, ImageConversationTask[]>();
|
||||||
|
for (const t of tasks) {
|
||||||
|
const key = t.batch_id || t.id; // 老任务可能无 batch_id,各自成批
|
||||||
|
const list = groups.get(key) || [];
|
||||||
|
list.push(t);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
|
||||||
|
const result: GenBatch[] = [];
|
||||||
|
for (const [key, list] of groups) {
|
||||||
|
const assets = list.flatMap((t) => t.assets || []);
|
||||||
|
const allTerminal = list.every((t) => TERMINAL.has(t.status));
|
||||||
|
const status: GenBatch["status"] = assets.length > 0 ? "done" : allTerminal ? "failed" : "generating";
|
||||||
|
const withId = assets.filter((a) => a.id);
|
||||||
|
result.push({
|
||||||
|
id: key,
|
||||||
|
prompt: list[0]?.prompt || "",
|
||||||
|
ratio: list[0]?.ratio || meta.ratio,
|
||||||
|
count: list.length,
|
||||||
|
status,
|
||||||
|
results: assets,
|
||||||
|
adopted: withId.length > 0 && withId.every((a) => a.in_library),
|
||||||
|
ts: new Date(list[0]?.created_at || Date.now()).getTime(),
|
||||||
|
// 该批用过的参考图(后端按 reference_image_ids 解析回 {name,url}),切换/刷新后仍可见
|
||||||
|
refs: (list[0]?.reference_images || []).length ? list[0].reference_images : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 旧批次在上、新批次在下(对话流自上而下时间序)
|
||||||
|
return result.sort((a, b) => a.ts - b.ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉某对话的历史批次并回显;非终态批次继续轮询补齐
|
||||||
|
const loadConvBatches = useCallback(async (convId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.conversationTasks(convId);
|
||||||
|
const next = batchesFromConvTasks(res.tasks);
|
||||||
|
setBatches(next);
|
||||||
|
// 仍在跑的批次(刷新时 worker 还没出完)继续轮询补齐
|
||||||
|
if (onResume) {
|
||||||
|
for (const b of next.filter((x) => x.status === "generating")) {
|
||||||
|
const ids = res.tasks.filter((t) => (t.batch_id || t.id) === b.id).map((t) => t.id);
|
||||||
|
if (!ids.length) continue;
|
||||||
|
onResume(mode, ids).then((r) => {
|
||||||
|
if (!r?.assets) return;
|
||||||
|
setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setBatches([]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mode, onResume]);
|
||||||
|
|
||||||
|
// 切换对话:置为 active 并回填它的批次
|
||||||
|
const selectConversation = useCallback((convId: string) => {
|
||||||
|
if (convId === activeConvRef.current) return;
|
||||||
|
setActiveConvId(convId);
|
||||||
|
setRenamingId("");
|
||||||
|
void loadConvBatches(convId);
|
||||||
|
}, [loadConvBatches]);
|
||||||
|
|
||||||
|
// 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流
|
||||||
|
async function handleNewConversation() {
|
||||||
|
try {
|
||||||
|
const conv = await api.createConversation({ mode, product: product?.id || null });
|
||||||
|
setConvError("");
|
||||||
|
setConversations((prev) => [conv, ...prev]);
|
||||||
|
setActiveConvId(conv.id);
|
||||||
|
setBatches([]);
|
||||||
|
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
|
||||||
|
setPickedIds([]);
|
||||||
|
} catch (err) {
|
||||||
|
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
|
||||||
|
setConvError(err instanceof Error ? err.message : "新建对话失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交重命名
|
||||||
|
async function commitRename(convId: string) {
|
||||||
|
const title = renameDraft.trim();
|
||||||
|
setRenamingId("");
|
||||||
|
if (!title) return;
|
||||||
|
setConversations((prev) => prev.map((c) => (c.id === convId ? { ...c, title } : c)));
|
||||||
|
try { await api.renameConversation(convId, title); } catch { void loadConversations(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除对话(软删):从列表移除;删的是当前对话则切到剩下第一条或清空
|
||||||
|
async function handleDeleteConversation(convId: string) {
|
||||||
|
const remaining = conversations.filter((c) => c.id !== convId);
|
||||||
|
setConversations(remaining);
|
||||||
|
if (convId === activeConvId) {
|
||||||
|
const nextActive = remaining[0]?.id || "";
|
||||||
|
setActiveConvId(nextActive);
|
||||||
|
if (nextActive) void loadConvBatches(nextActive);
|
||||||
|
else setBatches([]);
|
||||||
|
}
|
||||||
|
try { await api.deleteConversation(convId); } catch { void loadConversations(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表加载:image 模式才用对话(model/cover 走商品空间布局)。
|
||||||
|
// autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。
|
||||||
|
const loadConversations = useCallback(async (autoSelect = true) => {
|
||||||
|
if (mode !== "image") return;
|
||||||
|
setConvLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.listConversations(mode);
|
||||||
|
setConversations(res.results);
|
||||||
|
if (autoSelect && res.results.length && !activeConvRef.current) {
|
||||||
|
setActiveConvId(res.results[0].id);
|
||||||
|
void loadConvBatches(res.results[0].id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConversations([]);
|
||||||
|
} finally {
|
||||||
|
setConvLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mode, loadConvBatches]);
|
||||||
|
|
||||||
|
// 只在挂载 / 切 mode 时加载一次对话列表(不依赖 callback 身份,否则 onResume 每次渲染变更会触发反复重拉)
|
||||||
|
useEffect(() => { void loadConversations(); }, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
|
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
|
||||||
async function startBatch(opts: {
|
async function startBatch(opts: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -692,6 +915,8 @@ export function ImageWorkbenchPage({
|
|||||||
modelId?: string;
|
modelId?: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
platformIds?: string[];
|
platformIds?: string[];
|
||||||
|
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
|
||||||
|
refs?: { name: string; url: string; file?: File; assetId?: string }[];
|
||||||
}) {
|
}) {
|
||||||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
const newBatch: GenBatch = {
|
const newBatch: GenBatch = {
|
||||||
@@ -707,11 +932,36 @@ export function ImageWorkbenchPage({
|
|||||||
productTitle: opts.productTitle,
|
productTitle: opts.productTitle,
|
||||||
modelId: opts.modelId,
|
modelId: opts.modelId,
|
||||||
modelName: opts.modelName,
|
modelName: opts.modelName,
|
||||||
platformIds: opts.platformIds
|
platformIds: opts.platformIds,
|
||||||
|
// 批次头回显「参考了哪些图」(只存名+预览,不存 file)
|
||||||
|
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url }))
|
||||||
};
|
};
|
||||||
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, image_model: genModel });
|
// 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。
|
||||||
|
// 上传任一失败不阻断:跳过该张,有几张算几张。
|
||||||
|
let referenceImageIds: string[] | undefined;
|
||||||
|
if (opts.refs?.length) {
|
||||||
|
const ids = await Promise.all(
|
||||||
|
opts.refs.map(async (r) => {
|
||||||
|
if (r.assetId) return r.assetId;
|
||||||
|
if (!r.file) return null;
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", r.file);
|
||||||
|
form.append("asset_type", "image");
|
||||||
|
return api.uploadAsset(form).then((a) => a.id).catch(() => null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
referenceImageIds = ids.filter((x): x is string => !!x);
|
||||||
|
}
|
||||||
|
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
|
||||||
|
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, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds });
|
||||||
|
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
|
||||||
|
const convId = result?.conversation_id;
|
||||||
|
if (convId && convId !== activeConvRef.current) {
|
||||||
|
setActiveConvId(convId);
|
||||||
|
void loadConversations(false);
|
||||||
|
}
|
||||||
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);
|
||||||
@@ -728,6 +978,18 @@ export function ImageWorkbenchPage({
|
|||||||
|
|
||||||
async function runGenerate() {
|
async function runGenerate() {
|
||||||
if (!canGenerate) return;
|
if (!canGenerate) return;
|
||||||
|
// 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~60s 的生成在进行中也能切走/切回——
|
||||||
|
// 否则对话要等生成返回后才登记进列表,生成中根本看不到这条会话 → 切走就找不回 → loading 状态像丢了。
|
||||||
|
if (mode === "image" && !activeConvRef.current) {
|
||||||
|
try {
|
||||||
|
const conv = await api.createConversation({ mode, title: prompt.trim().slice(0, 24) || undefined });
|
||||||
|
activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到
|
||||||
|
setActiveConvId(conv.id);
|
||||||
|
setConversations((prev) => [conv, ...prev]);
|
||||||
|
} catch {
|
||||||
|
/* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */
|
||||||
|
}
|
||||||
|
}
|
||||||
const base = {
|
const base = {
|
||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
ratio,
|
ratio,
|
||||||
@@ -747,8 +1009,12 @@ export function ImageWorkbenchPage({
|
|||||||
void startBatch({
|
void startBatch({
|
||||||
...base,
|
...base,
|
||||||
modelId: mode === "model" ? pickedIds[0] : undefined,
|
modelId: mode === "model" ? pickedIds[0] : undefined,
|
||||||
modelName: mode === "model" ? pickedModelName : undefined
|
modelName: mode === "model" ? pickedModelName : undefined,
|
||||||
|
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
|
||||||
|
refs: mode === "image" && refImages.length ? refImages : undefined
|
||||||
});
|
});
|
||||||
|
// 参考图已交给本批,清空输入栏待下次(批次头会保留这次用过的参考图)
|
||||||
|
if (mode === "image") setRefImages([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
|
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
|
||||||
@@ -850,6 +1116,8 @@ export function ImageWorkbenchPage({
|
|||||||
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
|
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// image 模式的历史现由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
|
||||||
|
if (mode === "image") return;
|
||||||
// 1) 多批次结果回显
|
// 1) 多批次结果回显
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(batchKey);
|
const raw = localStorage.getItem(batchKey);
|
||||||
@@ -1128,26 +1396,68 @@ export function ImageWorkbenchPage({
|
|||||||
返回
|
返回
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="ic-new-conv" type="button" onClick={() => { setBatches([]); persistBatches([]); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
|
<button className="ic-new-conv" type="button" onClick={handleNewConversation}>
|
||||||
<Plus size={13} />
|
<Plus size={13} />
|
||||||
新对话
|
新对话
|
||||||
</button>
|
</button>
|
||||||
<div className="ic-side-sec">默认</div>
|
{convError && <div className="ic-conv-error">{convError}</div>}
|
||||||
<div className="ic-conv-list">
|
|
||||||
<div className="ic-conv-item active">
|
|
||||||
<div className="thumb default">
|
|
||||||
<ImagePlus size={13} />
|
|
||||||
</div>
|
|
||||||
<span className="nm">默认创作</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ic-side-sec">最近</div>
|
<div className="ic-side-sec">最近</div>
|
||||||
<div className="ic-conv-list">
|
<div className="ic-conv-list">
|
||||||
<div className="ic-conv-empty">
|
{conversations.length === 0 ? (
|
||||||
还没有最近会话
|
<div className="ic-conv-empty">
|
||||||
<br />
|
{convLoading ? "加载中…" : "还没有最近会话"}
|
||||||
<span className="mono">// NO HISTORY</span>
|
<br />
|
||||||
</div>
|
<span className="mono">// NO HISTORY</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
conversations.map((conv) => (
|
||||||
|
<div
|
||||||
|
className={`ic-conv-item ${conv.id === activeConvId ? "active" : ""}`}
|
||||||
|
key={conv.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => selectConversation(conv.id)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") selectConversation(conv.id); }}
|
||||||
|
>
|
||||||
|
<div className={`thumb ${conv.id === activeConvId ? "default" : ""}`}>
|
||||||
|
<ImagePlus size={13} />
|
||||||
|
</div>
|
||||||
|
{renamingId === conv.id ? (
|
||||||
|
<input
|
||||||
|
className="ic-conv-rename"
|
||||||
|
autoFocus
|
||||||
|
value={renameDraft}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
|
onBlur={() => commitRename(conv.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter") commitRename(conv.id);
|
||||||
|
if (e.key === "Escape") setRenamingId("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="nm">{conv.title || "未命名创作"}</span>
|
||||||
|
)}
|
||||||
|
<span className="ic-conv-acts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="重命名"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setRenamingId(conv.id); setRenameDraft(conv.title); }}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="删除对话"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteConversation(conv.id); }}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -1165,6 +1475,16 @@ export function ImageWorkbenchPage({
|
|||||||
</span>
|
</span>
|
||||||
<div className="pt">
|
<div className="pt">
|
||||||
<div className="pt-text">{batch.prompt}</div>
|
<div className="pt-text">{batch.prompt}</div>
|
||||||
|
{batch.refs && batch.refs.length > 0 && (
|
||||||
|
<div className="pt-refs">
|
||||||
|
{batch.refs.map((r, i) => (
|
||||||
|
<span className="pt-ref" key={`${r.name}-${i}`} title={r.name}>
|
||||||
|
<img src={r.url} alt={r.name} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="pt-ref-label">参考图 ×{batch.refs.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="pt-tags">
|
<div className="pt-tags">
|
||||||
<span className="meta-chip">{batch.ratio}</span>
|
<span className="meta-chip">{batch.ratio}</span>
|
||||||
<span className="sep">·</span>
|
<span className="sep">·</span>
|
||||||
@@ -1227,17 +1547,17 @@ export function ImageWorkbenchPage({
|
|||||||
<div className="ic-input-wrap">
|
<div className="ic-input-wrap">
|
||||||
<div className="ic-input">
|
<div className="ic-input">
|
||||||
<div className="ic-input-top">
|
<div className="ic-input-top">
|
||||||
<button className="add-btn" type="button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
|
<button className="add-btn" type="button" title="上传参考图(可多张)" onClick={() => refInputRef.current?.click()}>
|
||||||
<Plus size={22} />
|
<Plus size={22} />
|
||||||
</button>
|
</button>
|
||||||
<input ref={refInputRef} type="file" accept="image/*" hidden onChange={pickReference} />
|
<input ref={refInputRef} type="file" accept="image/*" multiple hidden onChange={pickReference} />
|
||||||
{refImage && (
|
{refImages.map((img, index) => (
|
||||||
<span className="meta-chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
<span className="meta-chip" key={`${img.name}-${index}`} style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
<img src={refImage.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
|
<img src={img.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
|
||||||
{refImage.name.slice(0, 16)}
|
{img.name.slice(0, 16)}
|
||||||
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImage(null)}>×</button>
|
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImages((prev) => prev.filter((_, i) => i !== index))}>×</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="ic-input-text"
|
className="ic-input-text"
|
||||||
@@ -1246,18 +1566,18 @@ export function ImageWorkbenchPage({
|
|||||||
placeholder="输入想法、剧本或上传参考,和 Agent 一起创作"
|
placeholder="输入想法、剧本或上传参考,和 Agent 一起创作"
|
||||||
/>
|
/>
|
||||||
<div className="ic-input-bottom">
|
<div className="ic-input-bottom">
|
||||||
|
<Pill
|
||||||
|
label="模型"
|
||||||
|
value={GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}
|
||||||
|
options={GEN_MODEL_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
|
||||||
|
onSelect={setGenModel}
|
||||||
|
/>
|
||||||
<Pill
|
<Pill
|
||||||
label="比例"
|
label="比例"
|
||||||
value={ratio}
|
value={ratio}
|
||||||
options={RATIO_OPTIONS.map((value) => ({ id: value, label: value }))}
|
options={RATIO_OPTIONS.map((value) => ({ id: value, label: value }))}
|
||||||
onSelect={setRatio}
|
onSelect={setRatio}
|
||||||
/>
|
/>
|
||||||
<Pill
|
|
||||||
label="风格"
|
|
||||||
value={STYLE_OPTIONS.find((s) => s.id === style)?.label || "默认"}
|
|
||||||
options={STYLE_OPTIONS}
|
|
||||||
onSelect={setStyle}
|
|
||||||
/>
|
|
||||||
<Pill
|
<Pill
|
||||||
label="张数"
|
label="张数"
|
||||||
value={count}
|
value={count}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type LoginProgress = "checking" | "entering";
|
|||||||
|
|
||||||
const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
|
const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
|
||||||
checking: "正在验证用户名和密码…",
|
checking: "正在验证用户名和密码…",
|
||||||
entering: "登录成功,正在进入 Airshelf"
|
entering: "登录成功,正在进入工作台…"
|
||||||
};
|
};
|
||||||
|
|
||||||
type FieldErrors = {
|
type FieldErrors = {
|
||||||
@@ -50,7 +50,7 @@ export function AuthScreen({
|
|||||||
}: {
|
}: {
|
||||||
initialMode: AuthMode;
|
initialMode: AuthMode;
|
||||||
onModeChange: (mode: AuthMode) => void;
|
onModeChange: (mode: AuthMode) => void;
|
||||||
onAuthed: (payload: { token: string; user: User; team: Team; remember?: boolean }) => void | Promise<void>;
|
onAuthed: (payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const remembered = getRemember();
|
const remembered = getRemember();
|
||||||
const [mode, setMode] = useState<AuthMode>(initialMode);
|
const [mode, setMode] = useState<AuthMode>(initialMode);
|
||||||
@@ -438,7 +438,7 @@ export function AuthScreen({
|
|||||||
{loginProgress && !error && (
|
{loginProgress && !error && (
|
||||||
<div className="login-progress" role="status" aria-live="polite">
|
<div className="login-progress" role="status" aria-live="polite">
|
||||||
<span className="login-progress-spinner" aria-hidden="true"></span>
|
<span className="login-progress-spinner" aria-hidden="true"></span>
|
||||||
<span>{inviteState.kind === "create_team" ? "正在创建团队,进入 Airshelf…" : "正在加入团队,进入 Airshelf…"}</span>
|
<span>{inviteState.kind === "create_team" ? "正在创建团队,进入工作台…" : "正在加入团队,进入工作台…"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <div className="form-error" role="alert">{error}</div>}
|
{error && <div className="form-error" role="alert">{error}</div>}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
const [cuName, setCuName] = useState("");
|
const [cuName, setCuName] = useState("");
|
||||||
const [cuRole, setCuRole] = useState("member");
|
const [cuRole, setCuRole] = useState("member");
|
||||||
const [cuDaily, setCuDaily] = useState("100");
|
const [cuDaily, setCuDaily] = useState("100");
|
||||||
const [cuMonthly, setCuMonthly] = useState("2000");
|
const [cuMonthly, setCuMonthly] = useState("100"); // 新成员默认月度限额 100(PMC#3)
|
||||||
const [cuTotal, setCuTotal] = useState("-1");
|
const [cuTotal, setCuTotal] = useState("-1");
|
||||||
|
|
||||||
// 编辑成员
|
// 编辑成员
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type AuthPayload = {
|
|||||||
token: string;
|
token: string;
|
||||||
user: User;
|
user: User;
|
||||||
team: Team;
|
team: Team;
|
||||||
|
role?: string; // 当前用户在该团队的角色(owner/admin/member);决定「团队」「消费」等仅主账号可见页面
|
||||||
};
|
};
|
||||||
|
|
||||||
// 平台后台 · 团队/用户管理视图模型
|
// 平台后台 · 团队/用户管理视图模型
|
||||||
@@ -529,13 +530,42 @@ export type ModelConfig = {
|
|||||||
export type AITask = {
|
export type AITask = {
|
||||||
id: string;
|
id: string;
|
||||||
task_type: string;
|
task_type: string;
|
||||||
|
// 工作台生成任务的分组键 / 模式(从 request_payload 抽出):同 batch_id = 一次提交的一批图;
|
||||||
|
// mode: model 模特上身图 / cover 平台套图 / image 图片创作。非工作台任务为 null。
|
||||||
|
batch_id?: string | null;
|
||||||
|
mode?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
idempotency_key: string;
|
idempotency_key?: string;
|
||||||
provider_task_id: string;
|
provider_task_id?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 图片创作工作室的「对话」(后端 ImageConversation)。左栏列表 / 切换 / 重命名 / 历史用。
|
||||||
|
export type ImageConversation = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
mode: "image" | "model" | "cover";
|
||||||
|
product: string | null;
|
||||||
|
task_count: number;
|
||||||
|
last_active_at: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换对话时回填:该对话下每个生图任务及其成图
|
||||||
|
export type ImageConversationTask = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
error_message: string;
|
||||||
|
prompt: string;
|
||||||
|
batch_id: string;
|
||||||
|
ratio: string;
|
||||||
|
reference_images: { name: string; url: string }[];
|
||||||
|
created_at: string;
|
||||||
|
assets: Asset[];
|
||||||
|
};
|
||||||
|
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user