fix: 测试清单遗留bug批量修复(15条:商品库/消费/团队/设置/消息中心/平台套图/资产库)

- R6 商品图删除不生效: 封面asset移交+绕开prefetch缓存, 前端封面缩图补删除入口
- R48 消息中心长文本与UI框错位: 胶囊单行省略+优先级标签禁压缩
- R70 账单流水筛选: 后端加ledger_type过滤, count随筛选变化, 切筛选重置页码
- R72 月限额改后不刷新: 团队页/消费页自取最新teamSettings, 两页同源
- R83/R86 平台套图提示词框加大+模型胶囊嵌入输入框(复用Pill组件)
- R96 未读角标: 修KeyTextTransform注解过滤触发MySQL 3141致接口500
- R100 工作台记录丢失: 新增GET /api/ai/tasks/workbench/ 同源恢复, 废除localStorage方案
- R104 卖点添加按钮移出输入框, 新建抽屉+编辑态统一
- R106 商品库宽屏锁5列, 10个/页铺满两行
- R108 资产删除改软删+单张删除+回收站二次确认; trash页商品/资产双分区+restore/purge端点
- R109 生成图自动入资产库(person演员立绘除外), 迁移0008存量回填, 移除全部加入资产库入口
- R65 无需改动(22268d6已修, 测旧部署所致)

验证: tsc/build/check/makemigrations --check 通过, 后端测试210/210, perf-probe PASS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-02 11:30:06 +08:00
co-authored by Claude Fable 5
parent 22268d608b
commit aab4d7ea4c
28 changed files with 924 additions and 476 deletions
+7 -4
View File
@@ -283,26 +283,29 @@ class AdminAssetReviewTests(TestCase):
self.a_active = Asset.objects.create(team=self.teamA, name="active", asset_type="image", category=Asset.Category.PERSON, review_status="active")
self.a_failed = Asset.objects.create(team=self.teamB, name="failed", asset_type="image", category=Asset.Category.PERSON, review_status="failed")
self.a_none = Asset.objects.create(team=self.teamA, name="none", asset_type="image", category=Asset.Category.PERSON, review_status="")
# 非 person 资产不该进队列
# scene 属于 REVIEW_CATEGORIES(场景图可能真人出镜,44e9023 扩审核范围),该进队列
Asset.objects.create(team=self.teamA, name="scene", asset_type="image", category=Asset.Category.SCENE, review_status="")
# 图片趴上传素材不在 REVIEW_CATEGORIES,不该进队列
Asset.objects.create(team=self.teamA, name="upload", asset_type="image", category=Asset.Category.UPLOAD, review_status="")
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_list_permission_and_only_person_cross_team(self):
def test_list_permission_and_review_scope_cross_team(self):
self.assertEqual(self.nc.get("/api/admin/asset-reviews/").status_code, 403)
r = self.ac.get("/api/admin/asset-reviews/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 4) # 4 person 资产(跨 2 团队),scene 排除
self.assertEqual(r.data["count"], 5) # 4 person + 1 scene(跨 2 团队),范围外 upload 排除
self.assertEqual({row["team_name"] for row in r.data["results"]}, {"TeamA", "TeamB"})
self.assertNotIn("upload", {row["name"] for row in r.data["results"]})
def test_filter_by_status(self):
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=processing").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=active").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=failed").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=none").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=none").data["count"], 2) # person a_none + scene
def test_submit_calls_review_and_audit(self):
with patch("apps.adminpanel.views.submit_asset_for_review") as mock_submit:
+5 -2
View File
@@ -2598,8 +2598,11 @@ def run_standalone_image_task(*, task_id: str) -> None:
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task,
metadata=asset_meta,
# 工作台生成的图默认不进资产库列表,只在工作台展示;用户「加入资产库」后才置 True
in_library=False,
# R109:图片生成(模特上身图/平台套图/图片创作/商品三视图)的成图**自动入资产库**,
# 不再由用户点「加入资产库」手动入库。仅「生成演员」(mode=model 无商品 → person)
# 仍暂存(in_library=False),由演员库「保存人物」时入库——否则未保存的立绘会
# 混进「我的演员」列表(ZWQ#6)。
in_library=asset_category != Asset.Category.PERSON,
)
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
+118
View File
@@ -427,19 +427,25 @@ class StandaloneCategoryTests(TestCase):
self.assertEqual(a.metadata.get("mode"), "model")
self.assertTrue(a.metadata.get("batch_id")) # 成组
self.assertEqual(a.metadata.get("model_entity_id"), "ent-1") # 溯源模特库
self.assertTrue(a.in_library) # R109:图片生成成图自动入资产库
def test_platform_kit_category(self):
a = self._run("cover", product_id=str(self.product.id))
self.assertEqual(a.category, Asset.Category.PLATFORM_KIT)
self.assertTrue(a.in_library) # R109
def test_free_create_category(self):
a = self._run("image")
self.assertEqual(a.category, Asset.Category.FREE_CREATE)
self.assertTrue(a.in_library) # R109
def test_generate_actor_stays_person(self):
# 生成演员:mode=model 但无 product → 视频角色,仍 person(送审范围内)
a = self._run("model")
self.assertEqual(a.category, Asset.Category.PERSON)
# R109 例外:演员立绘仍暂存(不自动入库),由演员库「保存人物」时置 True——
# 否则未保存立绘会混进「我的演员」列表(ZWQ#6)
self.assertFalse(a.in_library)
class TriviewAutoEnrollTests(TestCase):
@@ -544,3 +550,115 @@ class ChatStreamReasoningTests(SimpleTestCase):
self.assertEqual([e["type"] for e in events], ["reasoning", "reasoning", "delta", "delta", "done"])
self.assertEqual([e["text"] for e in events if e["type"] == "reasoning"], ["先想想", "用户要4镜"])
self.assertEqual("".join(e["text"] for e in events if e["type"] == "delta"), "正在生成脚本…")
class WorkbenchAndUnreadTests(TestCase):
"""R100(工作台记录后端持久化)+ R96(未读生成任务角标)+ R109(删除联动)端到端:
· /api/ai/tasks/workbench/ mode+product 回放批次流(与任务中心同源),软删的图不回显;
· /api/ai/tasks/unread/ 只统计 modemodel/cover/image 的未读任务,按团队隔离;
· /api/ai/tasks/mark-read/ 按商品清零"""
def setUp(self):
self.user = User.objects.create_user(username="wbowner", password="pass")
self.team = Team.objects.create(name="WB", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
CreditAccount.objects.create(team=self.team, balance="100.0000")
self.product = Product.objects.create(team=self.team, created_by=self.user, title="工作台商品")
cover = Asset.objects.create(
team=self.team, created_by=self.user, name="主图", asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD, category=Asset.Category.PRODUCT_IMAGE,
)
AssetFile.objects.create(asset=cover, object_key="c.png", bucket="b", content_type="image/png", preview_url="http://x/cover.png", is_primary=True)
self.product.cover_asset = cover
self.product.save(update_fields=["cover_asset"])
self.client = APIClient()
self.client.force_authenticate(self.user)
def _patch_provider(self):
provider = patch("apps.ai.services.get_image_provider").start()
prov = provider.return_value
prov.image_edit.return_value = {"data": [{"url": "http://x/out.png"}]}
prov.image_generation.return_value = {"data": [{"url": "http://x/out.png"}]}
prov.extract_first_media_url.return_value = "http://x/out.png"
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
media.return_value = (BytesIO(b"img"), "image/png")
store = patch("apps.ai.services.TosStorage").start()
stored = store.return_value.upload_fileobj.return_value
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3
self.addCleanup(patch.stopall)
return prov
def _generate(self, mode="cover", count=2, platform_id=None):
from apps.ai.services import run_standalone_image_task
self._patch_provider()
tasks = enqueue_standalone_images(
team=self.team, user=self.user, prompt="平台套图测试", mode=mode, count=count,
product_id=str(self.product.id), ratio="1:1", platform_id=platform_id,
)
for t in tasks:
run_standalone_image_task(task_id=str(t.id))
return tasks
def test_workbench_returns_batches_and_hides_deleted_assets(self):
tasks = self._generate(mode="cover", count=2, platform_id="douyin")
r = self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}")
self.assertEqual(r.status_code, 200, r.content)
rows = r.json()["tasks"]
self.assertEqual(len(rows), 2)
# 同一批共享 batch_id;prompt/ratio/platform/product 从 payload 抽出;每任务带成图
self.assertEqual(len({row["batch_id"] for row in rows}), 1)
self.assertEqual(rows[0]["prompt"], "平台套图测试")
self.assertEqual(rows[0]["ratio"], "1:1")
self.assertEqual(rows[0]["platform_id"], "douyin")
self.assertEqual(rows[0]["product_id"], str(self.product.id))
self.assertEqual(len(rows[0]["assets"]), 1)
# 其他 mode / 其他商品查不到这批
self.assertEqual(self.client.get("/api/ai/tasks/workbench/?mode=model").json()["tasks"], [])
# R108/R109:软删其中一张 → 该任务的 assets 随之为空(删除联动),资产不再回显
asset = Asset.objects.filter(origin_task=tasks[0]).first()
d = self.client.delete(f"/api/assets/{asset.id}/")
self.assertEqual(d.status_code, 204, d.content)
asset.refresh_from_db()
self.assertTrue(asset.is_deleted) # 回收站软删,不是物理删除
rows = self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}").json()["tasks"]
by_id = {row["id"]: row for row in rows}
self.assertEqual(by_id[str(tasks[0].id)]["assets"], [])
self.assertEqual(len(by_id[str(tasks[1].id)]["assets"]), 1)
def test_workbench_rejects_unknown_mode(self):
# mode 白名单:脚本 agent 复用的 auto/theme/revise 不能混进来
self.assertEqual(self.client.get("/api/ai/tasks/workbench/?mode=auto").status_code, 400)
self.assertEqual(self.client.get("/api/ai/tasks/workbench/").status_code, 400)
def test_unread_counts_and_mark_read(self):
self._generate(mode="cover", count=2, platform_id="douyin")
r = self.client.get("/api/ai/tasks/unread/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(body["total"], 2)
self.assertEqual(body["by_product"], {str(self.product.id): 2})
# 按商品标记已读 → 清零
m = self.client.post("/api/ai/tasks/mark-read/", {"product_id": str(self.product.id)}, format="json")
self.assertEqual(m.status_code, 200, m.content)
self.assertEqual(m.json()["updated"], 2)
body = self.client.get("/api/ai/tasks/unread/").json()
self.assertEqual(body["total"], 0)
self.assertEqual(body["by_product"], {})
def test_unread_is_team_scoped_and_mode_whitelisted(self):
self._generate(mode="cover", count=1)
# 非生成任务(脚本 agent 复用 mode=auto)不计入未读
AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.SCRIPT_GENERATION,
model_config=ModelConfig.objects.first(), idempotency_key="script-x",
request_payload={"mode": "auto"},
)
self.assertEqual(self.client.get("/api/ai/tasks/unread/").json()["total"], 1)
# 另一团队的用户看不到本团队的未读
other = User.objects.create_user(username="wbother", password="pass")
other_team = Team.objects.create(name="WB2", owner=other)
TeamMember.objects.create(team=other_team, user=other, role="owner", status="active")
c2 = APIClient()
c2.force_authenticate(other)
self.assertEqual(c2.get("/api/ai/tasks/unread/").json()["total"], 0)
+69 -4
View File
@@ -127,8 +127,11 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
_GEN_MODES = ("model", "cover", "image")
def _unread_base(self):
"""本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。"""
return self.get_queryset().filter(rp_mode__in=self._GEN_MODES, read_at__isnull=True)
"""本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。
过滤必须走 request_payload__mode 路径查找而非 rp_mode 注解:KeyTextTransform 注解上的
exact/in 查找在 MySQL 会把裸字符串塞进 JSON_EXTRACT 当文档解析 3141 全查询炸
(sqlite 编译路径不同,单测测不出来);路径查找 RHS 会被正确 JSON 编码"""
return self.get_queryset().filter(request_payload__mode__in=self._GEN_MODES, read_at__isnull=True)
@action(detail=False, methods=["get"], url_path="unread")
def unread(self, request):
@@ -142,6 +145,68 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
by_product[str(pid)] = by_product.get(str(pid), 0) + 1
return Response({"total": total, "by_product": by_product})
@action(detail=False, methods=["get"], url_path="workbench")
def workbench(self, request):
"""R100:工作台(模特上身图/平台套图/图片创作)的生成记录改从后端持久数据拉取 ——
原先只存前端 localStorage(1 小时过期换浏览器/清缓存即空),任务中心有记录而工作台丢
与任务中心同源 = 同一批 AITask; request_payload.mode(白名单)+ product 过滤,
附带每个任务的存活成图资产,前端按 batch_id 归批还原批次流
性能:仍只用 KeyTextTransform SQL 层抽 payload 标量defer 两个巨型 payload ,不整列拖出"""
from django.db.models.fields.json import KeyTextTransform
mode = str(request.query_params.get("mode") or "").strip()
if mode not in self._GEN_MODES:
return Response({"detail": "mode 仅支持 model / cover / image"}, status=status.HTTP_400_BAD_REQUEST)
try:
limit = min(500, max(1, int(request.query_params.get("limit") or 200)))
except (TypeError, ValueError):
limit = 200
qs = (
AITask.objects.filter(
team=self.get_team(),
project__isnull=True, # 工作台独立生图不挂项目;排除流水线内部任务
task_type__in=[AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE],
)
.defer("request_payload", "response_payload")
.annotate(
rp_mode=KeyTextTransform("mode", "request_payload"),
rp_batch_id=KeyTextTransform("batch_id", "request_payload"),
rp_product_id=KeyTextTransform("product_id", "request_payload"),
rp_prompt=KeyTextTransform("prompt", "request_payload"),
rp_ratio=KeyTextTransform("ratio", "request_payload"),
rp_platform_id=KeyTextTransform("platform_id", "request_payload"),
rp_model_id=KeyTextTransform("model_id", "request_payload"),
rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"),
)
.filter(request_payload__mode=mode) # 路径查找,不能用 rp_mode 注解比较(MySQL 3141,见 _unread_base)
)
product_id = str(request.query_params.get("product") or "").strip()
if product_id:
qs = qs.filter(request_payload__product_id=product_id)
tasks = list(qs.order_by("-created_at").prefetch_related("generated_assets", "generated_assets__files")[:limit])
tasks.reverse() # 旧 → 新,与对话流/工作台批次流的时间序一致
data = [
{
"id": str(t.id),
"status": t.status,
"error_message": t.error_message,
"prompt": t.rp_prompt or "",
"batch_id": t.rp_batch_id or "",
"ratio": t.rp_ratio or "",
"product_id": t.rp_product_id or "",
"model_id": t.rp_model_id or "",
"model_entity_id": t.rp_model_entity_id or "",
"platform_id": t.rp_platform_id or "",
"created_at": t.created_at,
# 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动)
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
).data,
}
for t in tasks
]
return Response({"tasks": data})
@action(detail=False, methods=["post"], url_path="mark-read")
def mark_read(self, request):
"""标记已读 → 清零未读胶囊。
@@ -154,9 +219,9 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
if isinstance(ids, str):
ids = [s for s in ids.split(",") if s.strip()]
if product_id:
qs = qs.filter(rp_product_id=product_id)
qs = qs.filter(request_payload__product_id=product_id)
if batch_id:
qs = qs.filter(rp_batch_id=batch_id)
qs = qs.filter(request_payload__batch_id=batch_id)
if ids:
qs = qs.filter(id__in=[str(i).strip() for i in ids if str(i).strip()])
updated = qs.update(read_at=timezone.now())
@@ -0,0 +1,29 @@
# R109:图片生成(工作台)产物自动入资产库 —— 回填历史存量。
# 影响范围只限图片生成三类产物(model_tryon 模特上身图 / platform_kit 平台套图 / free_create 自由创作):
# 这三个 category 只由独立生图 worker 产出,把其中 in_library=False(旧「未加入资产库」)的置 True。
# 不碰 person(演员立绘暂存,由「保存人物」入库,ZWQ#6)、不碰其他任何分类。
from django.db import migrations
def backfill_workbench_in_library(apps, schema_editor):
Asset = apps.get_model("assets", "Asset")
Asset.objects.filter(
category__in=["model_tryon", "platform_kit", "free_create"],
in_library=False,
).update(in_library=True)
def noop(apps, schema_editor):
# 不可逆:回滚不把这些图再踢出资产库(无害)
pass
class Migration(migrations.Migration):
dependencies = [
("assets", "0007_asset_in_library"),
]
operations = [
migrations.RunPython(backfill_workbench_in_library, noop),
]
+46
View File
@@ -105,6 +105,52 @@ class AssetSoftDeleteTests(TestCase):
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
class AssetTrashApiTests(TestCase):
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 硬删且被引用时 409。"""
def setUp(self):
self.user, self.team = _mk_team("utr", "TeamTR")
_, self.other_team = _mk_team("utr2", "TeamTR2")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.live = Asset.objects.create(team=self.team, name="活图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
self.dead = Asset.objects.create(team=self.team, name="删掉的图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON, is_deleted=True)
# 别队软删资产:不得出现在本队垃圾桶(team 隔离)
self.other_dead = Asset.objects.create(team=self.other_team, name="别队删图", asset_type="image", source="ai_generated", is_deleted=True)
def test_trash_lists_only_own_deleted(self):
ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
self.assertEqual(ids, {str(self.dead.id)}) # 只含本队软删;不含活图 / 别队删图
def test_restore_makes_asset_visible_in_library_again(self):
res = self.client.post(f"/api/assets/{self.dead.id}/restore/")
self.assertEqual(res.status_code, 200)
self.dead.refresh_from_db()
self.assertFalse(self.dead.is_deleted)
ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]}
self.assertIn(str(self.dead.id), ids) # 资产库重新可见
# 恢复后不再在垃圾桶
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
def test_purge_hard_deletes(self):
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
self.assertEqual(res.status_code, 204)
self.assertFalse(Asset.objects.filter(id=self.dead.id).exists())
def test_purge_referenced_asset_returns_409(self):
from apps.products.models import Product, ProductImage
product = Product.objects.create(team=self.team, created_by=self.user, title="P")
ProductImage.objects.create(product=product, asset=self.dead) # PROTECT 引用
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
self.assertEqual(res.status_code, 409)
self.assertTrue(Asset.objects.filter(id=self.dead.id, is_deleted=True).exists()) # 留在垃圾桶可恢复
def test_restore_rejects_live_asset(self):
# 未删资产不在 trash 态 queryset → 404(与商品垃圾桶口径一致)
self.assertEqual(self.client.post(f"/api/assets/{self.live.id}/restore/").status_code, 404)
class VideoPacksTests(TestCase):
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
+50 -1
View File
@@ -5,6 +5,7 @@ import uuid
import requests
from django.db import transaction
from django.db.models import Q
from django.db.models.deletion import ProtectedError
from django.http import StreamingHttpResponse
from rest_framework import status
from rest_framework.decorators import action
@@ -99,11 +100,24 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
def perform_destroy(self, instance):
"""R108:资产删除 = 回收站软删(is_deleted 置位),不再物理删行(原默认 hard delete 不可恢复)。
资产库列表/批次/任务中心批次弹窗/工作台记录/对话历史引用的都是同一 Asset 查询(均过滤
is_deleted),软删后各处即联动消失(R109生成记录与资产库数据绑定)TOS 文件保留,可恢复"""
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
def get_queryset(self):
"""服务端过滤,支持各页按需懒加载(不再前端取全量后客户端切片)。
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering
默认按 -created_at 排序 ORDER BY 时分页会重/"""
qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库
qs = super().get_queryset()
# R108 垃圾桶:trash/restore/purge 只看软删资产(镜像商品垃圾桶的写法)。
# perform_destroy 软删时 update updated_at → -updated_at ≈ 删除时间倒序;不过滤 in_library
# (未入库的工作台图删了也得能恢复)。基类 queryset 的 defer(AITask 巨型 payload)保持生效。
if self.action in ("trash", "restore", "purge"):
return qs.filter(is_deleted=True).order_by("-updated_at")
qs = qs.filter(is_deleted=False) # 软删资产不出现在资产库
p = self.request.query_params
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
@@ -244,6 +258,41 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
asset.save(update_fields=["in_library"])
return Response({"id": str(asset.id), "in_library": asset.in_library})
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已软删(is_deleted=True)的资产 —— R108 删除弹窗承诺
删除的图片将回收到垃圾桶,此口兑现承诺(与商品垃圾桶 /api/products/trash/ 同构)"""
qs = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(qs)
if page is not None:
return self.get_paginated_response(self.get_serializer(page, many=True).data)
return Response(self.get_serializer(qs, many=True).data)
@action(detail=True, methods=["post"], url_path="restore")
def restore(self, request, pk=None):
"""从垃圾桶恢复:is_deleted → False。资产库/批次/商品素材区引用的都是同一 Asset 查询
(均过滤 is_deleted),恢复后各处即联动复现;in_library 保持删除前原样,不额外置位"""
asset = self.get_object() # trash 态 queryset:只有软删资产可恢复
asset.is_deleted = False
asset.save(update_fields=["is_deleted", "updated_at"])
return Response(self.get_serializer(asset).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除(仅垃圾桶内软删资产可彻底删,不可恢复)。与商品 purge 语义对齐:
硬删 DB (AssetFile 记录级联删除),TOS 对象不动(商品 purge 同样不触碰对象存储)
分镜/视频片段/商品图等以 PROTECT 引用资产:被引用的资产硬删会炸 ProtectedError,
如实回 409(留在垃圾桶,仍可恢复),别让它 500"""
asset = self.get_object()
try:
asset.delete()
except ProtectedError:
return Response(
{"detail": "该资产仍被商品图/分镜/视频片段等引用,无法彻底删除;可恢复后先解除引用"},
status=status.HTTP_409_CONFLICT,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="video-packs")
def video_packs(self, request):
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
+32
View File
@@ -100,6 +100,38 @@ class MemberLimitTests(TestCase):
reserve_credit(team=self.team, user=self.user, task=self._task(f"z{i}"), amount=Decimal("10.0000"))
class LedgerTypeFilterTests(TestCase):
"""账单流水按类型过滤(R70):count/结果随 ledger_type 变化,未知类型忽略回全量。"""
def setUp(self):
self.owner = User.objects.create_user(username="lf-owner", password="ownerpass123")
self.team = Team.objects.create(name="Ledger Filter Team", owner=self.owner)
TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER, status=TeamMember.Status.ACTIVE)
CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000"))
for i, ltype in enumerate([CreditLedger.Type.RECHARGE, CreditLedger.Type.CHARGE, CreditLedger.Type.CHARGE]):
CreditLedger.objects.create(
team=self.team, user=self.owner, ledger_type=ltype,
amount=Decimal("10.0000"), balance_after=Decimal("100.0000"), reason=f"row {i}",
)
self.client_api = APIClient()
self.client_api.force_authenticate(self.owner)
def test_filter_by_type_changes_count_and_rows(self):
response = self.client_api.get("/api/billing/ledgers/?ledger_type=charge")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 2)
self.assertTrue(all(row["ledger_type"] == "charge" for row in response.data["results"]))
response = self.client_api.get("/api/billing/ledgers/?ledger_type=recharge")
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["ledger_type"], "recharge")
def test_unknown_type_returns_all(self):
response = self.client_api.get("/api/billing/ledgers/?ledger_type=bogus")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 3)
class RechargePermissionTests(TestCase):
"""充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客被 403 拦截。"""
+4
View File
@@ -59,10 +59,14 @@ def ledgers(request):
)
project_id = request.query_params.get("project")
user_id = request.query_params.get("user")
# 按类型过滤(账户页「全部类型/扣费/充值…」下拉):count/分页随过滤变化,未知值忽略
ledger_type = request.query_params.get("ledger_type")
if project_id:
queryset = queryset.filter(project_id=project_id)
if user_id:
queryset = queryset.filter(user_id=user_id)
if ledger_type and ledger_type in CreditLedger.Type.values:
queryset = queryset.filter(ledger_type=ledger_type)
# 服务端分页:总数随流水增长(原先写死 [:100] 导致永远 100 条)
try:
page = max(1, int(request.query_params.get("page", 1)))
+21
View File
@@ -132,3 +132,24 @@ class ProductImageDeleteGuardTests(TestCase):
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img0.id}/") # 再删最后一张
self.assertEqual(res.status_code, 400)
self.assertEqual(self.product.images.count(), 1) # 没删掉,仍保留一张
def test_delete_cover_image_reassigns_cover(self):
# YYX R6:被删的图正是封面来源(创建时 cover_asset = 第一张图)→ 封面移交给剩余首图,
# 否则 cover_asset 悬空指向已移除的图,前端会把它重建成「没有删除入口的封面缩图」,体感删除不生效。
self.product.cover_asset = self.img0.asset
self.product.save(update_fields=["cover_asset"])
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img0.id}/")
self.assertEqual(res.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.cover_asset_id, self.img1.asset_id) # 封面移交剩余首图
data = res.json()
self.assertEqual(data["cover_asset"], str(self.img1.asset_id))
self.assertNotIn(str(self.img0.asset_id), [im["asset"] for im in data["images"]]) # 被删图不再回来
def test_delete_non_cover_image_keeps_cover(self):
self.product.cover_asset = self.img0.asset
self.product.save(update_fields=["cover_asset"])
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img1.id}/")
self.assertEqual(res.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.cover_asset_id, self.img0.asset_id) # 封面不受影响
+14 -2
View File
@@ -206,13 +206,25 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""移除商品图(删 ProductImage 关联,保留底层 Asset)。
商品至少保留一张图(创建必须带图一致):删到最后一张则拒绝 要清空只能删整个商品"""
product = self.get_object()
if not ProductImage.objects.filter(product=product, id=image_id).exists():
image = ProductImage.objects.filter(product=product, id=image_id).first()
if image is None:
return Response({"detail": "image not found"}, status=status.HTTP_404_NOT_FOUND)
if product.images.count() <= 1:
return Response(
{"detail": "商品至少保留一张图片,如需移除请删除整个商品(或先上传新图再删旧图)"},
status=status.HTTP_400_BAD_REQUEST,
)
ProductImage.objects.filter(product=product, id=image_id).delete()
deleted_asset_id = image.asset_id
image.delete()
# 被删的图若正是封面来源(创建时 cover_asset = 第一张图的 asset):把封面移交给剩余首图,
# 否则 cover_asset 悬空指向已移除的图 → 序列化时它不在 images 里,前端会把它重建成
# 「无 ProductImage 关联、没有删除入口的封面缩图」,体感「弹了删除成功但图又回来了,
# 悬停也不出垃圾桶」(YYX R6)。
if product.cover_asset_id == deleted_asset_id:
# ★ 不走 product.images(get_object 的 prefetch 缓存删完不失效,会把刚删的图再取出来),
# 直接查表拿剩余首图(Meta.ordering = sort_order, created_at)。
next_image = ProductImage.objects.filter(product=product).first()
product.cover_asset_id = next_image.asset_id if next_image else None
product.save(update_fields=["cover_asset", "updated_at"])
product.refresh_from_db()
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)
+31 -50
View File
@@ -633,21 +633,39 @@
.image-workbench .cg-logo.has-img { background: var(--surface); overflow: hidden; }
.image-workbench .p-logo-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
/* ── 提示词框(YYX#7 加大)+ 框内生图模型胶囊(YYX#10 靠左置底)── */
.image-workbench .iw-prompt-box { position: relative; }
/* 提示词框(YYX#7/R83 加大)+ 框内生图模型胶囊(YYX#10/R86 靠左置底)
外层 .iw-prompt-box 即视觉上的输入框(复用 §4.3 输入族 default/hover/focus 三态),
内部 = 无边框 textarea(4-5 行起步,随内容自动增高,可手动拉伸)+ 底部工具条
胶囊复用 chat 输入栏 .ic-param(r-pill 真胶囊 · 菜单向上弹),不再绝对定位盖在文字上 */
.image-workbench .iw-prompt-box {
background: var(--surface);
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-md);
display: flex; flex-direction: column;
transition: border-color var(--t-base), box-shadow var(--t-base);
}
.image-workbench .iw-prompt-box:hover { border-color: var(--black-alpha-24); }
.image-workbench .iw-prompt-box:focus-within {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
.image-workbench .iw-prompt-text {
min-height: 148px; /* 原共享 .textarea 88px → 工作台提示词框加大 */
padding-bottom: 42px; /* 给底部胶囊让位,文字不被遮 */
width: 100%;
border: 0; outline: 0;
background: transparent;
font-family: inherit; font-size: 14px; line-height: 1.6;
color: var(--accent-black);
padding: 12px 14px 4px;
min-height: 124px; /* 原共享 .textarea 88px → 约 4-5 行起步 */
max-height: 360px;
resize: vertical; /* 允许手动拉伸(容器随之增高) */
field-sizing: content; /* 支持的浏览器随内容在 min/max 间自动增高 */
}
.image-workbench .iw-prompt-text::placeholder { color: var(--black-alpha-48); }
.image-workbench .iw-prompt-bar {
position: absolute;
left: 10px; bottom: 10px;
display: flex; align-items: center;
display: flex; align-items: center; gap: 8px;
padding: 6px 12px 10px;
}
.image-workbench .iw-model-pill { position: relative; }
.image-workbench .iw-model-pill .chip { height: 26px; }
/* 胶囊在框底 → 下拉菜单向上弹,避免被框/CTA 遮住 */
.image-workbench .iw-model-pill .chip-menu { top: auto; bottom: calc(100% + 4px); }
/* YYX#17:左侧商品栏折叠 复用导航栏(app-shell .sidebar-toggle)同款视觉/交互
贴商品栏右边缘的竖条按钮:平时隐形(chevron opacity 0),hover 才浮现 chevron + 浅底,
@@ -941,22 +959,10 @@
.image-workbench .gen-image-feedback svg { width: 20px; height: 20px; }
.image-workbench .gen-image-feedback span { font-size: 12px; font-weight: 500; letter-spacing: .02em; }
/* §4.18 ③ 左上「已采用」绿角标(批次 adopted 常驻 · forest token) */
.image-workbench .gen-adopt-badge {
position: absolute; top: 8px; left: 8px;
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 8px 0 6px;
background: var(--accent-forest);
color: var(--accent-white);
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
letter-spacing: .02em;
border-radius: var(--r-pill);
z-index: 3;
}
.image-workbench .gen-adopt-badge svg { width: 11px; height: 11px; }
/* R109:成图自动入资产库 —— 「已入库」绿角标 / 单图加入按钮已随功能移除 */
/* row38:任务中心批次弹窗(createPortal body,不在 .image-workbench 作用域内)
自带一份 gen 网格 + 入库徽标/单图加入按钮样式,复用主工作台视觉 */
自带一份 gen 网格样式,复用主工作台视觉 */
.modal .gen-images {
display: grid;
grid-template-columns: repeat(var(--cols, 4), 1fr);
@@ -973,31 +979,6 @@
}
.modal .gen-image-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.modal .gen-image .placeholder { position: absolute; inset: 0; display: grid; place-items: center; }
.modal .gen-image.adopted { outline: 2px solid var(--accent-forest); outline-offset: -2px; }
.modal .gen-adopt-badge {
position: absolute; top: 8px; left: 8px;
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 8px 0 6px;
background: var(--accent-forest); color: var(--accent-white);
font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: .02em;
border-radius: var(--r-pill); z-index: 3;
}
.modal .gen-adopt-badge svg { width: 11px; height: 11px; }
/* 单图「加入资产库 / 取消」按钮:右下角悬浮,hover 出现;已入库态常驻橙 */
.tc-lib-btn {
position: absolute; right: 8px; bottom: 8px; z-index: 3;
display: inline-flex; align-items: center; gap: 4px;
height: 26px; padding: 0 10px;
border: 0; border-radius: var(--r-pill);
background: rgba(38, 38, 38, .82); color: var(--accent-white);
font-size: 11px; font-weight: 500; letter-spacing: .02em;
cursor: pointer; opacity: 0;
transition: opacity var(--t-base), background var(--t-base);
}
.modal .gen-image:hover .tc-lib-btn { opacity: 1; }
.tc-lib-btn.on { background: var(--heat); opacity: 1; }
.tc-lib-btn:disabled { opacity: .5; cursor: not-allowed; }
.tc-lib-btn svg { width: 13px; height: 13px; }
/* 底部操作按钮行(§4.18 .gen-card-actions · 二级 + ghost · 不放主橙) */
.image-workbench .gen-card-actions {
+28 -4
View File
@@ -154,6 +154,16 @@ export const api = {
deleteAsset(id: string) {
return request<void>(`/api/assets/${id}/`, { method: "DELETE" });
},
// R108 资产垃圾桶(与商品垃圾桶同构):已软删资产列表 / 恢复回资产库 / 彻底删除(不可恢复)
assetsTrash() {
return request<Paginated<Asset>>("/api/assets/trash/");
},
restoreAsset(id: string) {
return request<Asset>(`/api/assets/${id}/restore/`, { method: "POST" });
},
purgeAsset(id: string) {
return request<void>(`/api/assets/${id}/purge/`, { method: "DELETE" });
},
preferences() {
return request<UserPreference>("/api/auth/me/preferences/");
},
@@ -176,6 +186,10 @@ export const api = {
teamMembers() {
return request<TeamMember[]>("/api/auth/team/members/");
},
// 团队级设置读取(任意成员可读):团队页/消费页取最新月限额,避免依赖登录时的陈旧 /me/ 快照(R72)
teamSettings() {
return request<Team>("/api/auth/team/settings/");
},
// 团队级月限额持久化(超管在团队页设置 · PMC#12);monthly_credit_limit: 数值 / null(清空)
updateTeamSettings(payload: { monthly_credit_limit?: number | string | null }) {
return request<Team>("/api/auth/team/settings/", { method: "PATCH", body: JSON.stringify(payload) });
@@ -501,8 +515,7 @@ export const api = {
submitAssetReview(id: string) {
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
},
// 单图「加入资产库 / 取消加入」:置 in_library。加入 → 出现在资产库列表;取消 → 移出列表。
// 资产生成时已落库并扣费,此口只控制是否进资产库展示列表,不增删真实资产、不二次扣费。
// 置 in_library(现仅演员库「保存人物」用:立绘暂存 → 入库;R109 后图片生成成图已自动入库)
setAssetLibrary(id: string, inLibrary: boolean) {
return request<{ id: string; in_library: boolean }>(`/api/assets/${id}/set-library/`, { method: "POST", body: JSON.stringify({ in_library: inLibrary }) });
},
@@ -585,8 +598,12 @@ export const api = {
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
ledgers(page = 1, pageSize = 10) {
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?page=${page}&page_size=${pageSize}`);
ledgers(page = 1, pageSize = 10, filters?: { ledger_type?: string; user?: string }) {
// 类型/成员筛选传参给后端做全量过滤(count/总页数随筛选变化 · R70)
const q = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filters?.ledger_type) q.set("ledger_type", filters.ledger_type);
if (filters?.user) q.set("user", filters.user);
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?${q.toString()}`);
},
billingTrend(range?: "day" | "week" | "month") {
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
@@ -600,6 +617,13 @@ export const api = {
// 以便 全部/已完成/失败 三个 tab 数按真实总量算。
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200&task_type=person_image,product_image");
},
// R100:工作台生成记录(模特上身图/平台套图)——从后端持久任务拉取,与任务中心同源,
// 刷新/换浏览器不丢;按 mode(+product)过滤,前端按 batch_id 归批。
workbenchTasks(mode: "model" | "cover" | "image", productId?: string) {
const qs = new URLSearchParams({ mode });
if (productId) qs.set("product", productId);
return request<{ tasks: import("./types").WorkbenchTask[] }>(`/api/ai/tasks/workbench/?${qs.toString()}`);
},
// YYX#row22:未读生成任务汇总(导航胶囊总数 + 按商品分组角标)
aiTasksUnread() {
return request<{ total: number; by_product: Record<string, number> }>("/api/ai/tasks/unread/");
+1 -1
View File
@@ -26,7 +26,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
{ 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, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) {
@@ -283,9 +283,15 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
</div>
<div className="field pc-field-last">
<label className="field-label"><span className="req">*</span></label>
{/* YYX#17: = ;,
*/}
{/* YYX#17/R104:(,+ ),
/线; = , */}
<div className="field-label-row">
<label className="field-label"><span className="req">*</span></label>
<button type="button" className="bl-add-btn" onClick={addBullet}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
</div>
<ul className="bullet-list">
{bullets.map((bullet, index) => (
<li className="bl-item bl-item-edit" key={index}>
@@ -294,6 +300,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
className="bl-input"
value={bullet}
onChange={(event) => updateBullet(index, event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
placeholder="例: 玻尿酸双效保湿,4 小时持久水润"
/>
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
@@ -302,10 +309,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
</li>
))}
</ul>
<button type="button" className="bl-add-row" onClick={addBullet}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
</div>
</div>
</div>
+4
View File
@@ -236,3 +236,7 @@ body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important;
.pack-clip.as-btn:hover img { opacity: .88; }
.pack-clip .pack-clip-ph { width: 100%; aspect-ratio: 9 / 16; display: flex; align-items: center; justify-content: center; border-radius: var(--r-sm); background: var(--background-lighter); }
.pack-clip-name { font-size: 11px; color: var(--black-alpha-48); margin-top: 4px; text-align: center; letter-spacing: .02em; }
/* R108:批次弹窗内单张悬浮删除 icon(复用共享 .card-del-btn,这里只补 hover 触发,不改共享类本体) */
.pack-modal .pack-clip { position: relative; }
.pack-modal .pack-clip:hover .card-del-btn,
.pack-modal .pack-clip:focus-within .card-del-btn { opacity: 1; }
+13
View File
@@ -248,6 +248,19 @@
font-size: 10px;
letter-spacing: .02em;
}
/* 列表底部标签行:项目名过长时胶囊内单行省略胶囊定高 20px,若放任文字换行
第二行会溢出到胶囊框外(文字与框错位);inline-block + ellipsis 把长名收在框内 */
.msg-item-foot .msg-priority {
display: inline-block;
min-width: 0;
max-width: 100%;
line-height: 18px; /* 20px 高 - 上下 1px 边框,单行文字垂直居中 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 优先级标签(更新/成功…)内容短且固定,不许被长项目名挤压到文字竖排 */
.msg-item-foot .msg-priority:first-child { flex-shrink: 0; }
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
.msg-priority.warn { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
.msg-priority.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
+16 -12
View File
@@ -436,19 +436,23 @@
}
.pc-drawer .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
.pc-drawer .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; }
/* YYX#17:独立添加卖点按钮 列表下方整行虚线按钮,点一下新增一条可编辑卖点行
(替代原来在一个框里回车隐晦追加)二级按钮观感,scoped 不污染全局 */
.pc-drawer .form-card .bl-add-row {
width: 100%;
display: flex; align-items: center; justify-content: center; gap: 6px;
height: 38px;
background: transparent;
border: 1px dashed var(--border-loud); border-radius: var(--r-md);
font-size: 13px; color: var(--heat); font-family: inherit; cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
/* YYX#17/R104:独立添加卖点按钮 挂在字段标题行右侧的小按钮(对齐+ 新增角色摆法),
不再是列表下方整行虚线框(测试反馈那种观感像又一个输入框)scoped 不污染全局 */
.pc-drawer .form-card .field-label-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.pc-drawer .form-card .bl-add-row:hover { background: var(--heat-12); border-color: var(--heat-40); }
.pc-drawer .form-card .bl-add-row svg { width: 14px; height: 14px; }
.pc-drawer .form-card .field-label-row .field-label { margin-bottom: 0; }
.pc-drawer .form-card .bl-add-btn {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border-loud); border-radius: var(--r-sm);
font-size: 12px; color: var(--accent-black); font-family: inherit; cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.pc-drawer .form-card .bl-add-btn:hover { background: var(--heat-12); border-color: var(--heat-40); color: var(--heat); }
.pc-drawer .form-card .bl-add-btn svg { width: 12px; height: 12px; }
@media (max-width: 900px) {
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
}
+13
View File
@@ -320,6 +320,19 @@
}
.v-bullet-list .bl-x:hover { color: var(--accent-crimson, #c43d3d); background: var(--crimson-bg, #fdebea); }
.v-bullet-list .bl-x svg { width: 11px; height: 11px; }
/* R104:显式添加卖点按钮(列表下方独立小按钮,不再是嵌在列表里的+ 输入框),
与新建商品抽屉 .bl-add-btn 同观感;仅编辑态显示(覆盖 .v-edit display:block) */
.bl-add-btn {
align-items: center; gap: 5px;
height: 26px; padding: 0 10px; margin-top: 2px;
background: var(--surface);
border: 1px solid var(--border-loud); border-radius: var(--r-sm);
font-size: 12px; color: var(--accent-black); font-family: inherit; cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.ov-card.editing .v-edit.bl-add-btn { display: inline-flex; }
.bl-add-btn:hover { background: var(--heat-12); border-color: var(--heat-40); color: var(--heat); }
.bl-add-btn svg { width: 12px; height: 12px; }
/* 编辑模式下,商品图片显示一个 [+ 上传] 占位 */
.img-upload {
+7 -10
View File
@@ -14,16 +14,13 @@
.chip .chip-count { display: inline-flex; align-items: center; justify-content: center; height: 18px; min-width: 18px; padding: 0 5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); border-radius: var(--r-pill); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; margin-left: 2px; }
.product-grid-wrap { margin: 0 -8px; padding: 2px 8px 24px; }
/* 自适应铺满:auto-fill + minmax,列数随容器宽度增长(不写死列数 max-width 截断)
普通 240px 每张;超宽屏(>1920px)把下限收到 220px,让多出来的横向空间真的再多排
1-2 列商品,而不是把现有几张拉得过宽右侧空出可放两张的位置(YYX#20) */
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
@media (min-width: 1920px) {
.product-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
}
@media (min-width: 2560px) {
.product-grid { grid-template-columns: repeat(auto-fill, minmax(208px, 1fr)); }
}
/* 自适应铺满 + 宽屏封顶 5 (YYX#20 / R106):
auto-fill(每张 240px)随宽度自动增列(1440 视口 4 ,维持现状);
一旦 5 列每列已 240px(网格 1264px, 1570 视口起)则锁定 5
每页 10 个商品正好铺满两整行,卡片随宽度等比放大铺满可用宽,
不再出现 1920 视口下 6/7 × 每页 10 = 末行空出两三个商品位
(缩略图走 aspect-ratio 1.4/1 max-height,放大不会被反向钳宽) */
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(240px, calc((100% - 4 * 16px) / 5)), 1fr)); gap: 16px; }
.product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; }
.product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.product-thumb { aspect-ratio: 1.4 / 1; }
+29 -29
View File
@@ -129,29 +129,40 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
// 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载
const [trend, setTrend] = useState<BillingTrend | null>(null);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
// 团队级月限额本页自取最新值:team prop 来自登录时 /me/ 快照,团队页改完月限额后不刷新会陈旧,
// 造成本页「本月限额」与团队页「月限额」对不上(R72)
const [freshTeam, setFreshTeam] = useState<Team | null>(null);
useEffect(() => {
let alive = true;
api.billingTrend().then((d) => { if (alive) setTrend(d); }).catch(() => {});
api.teamMembers().then((m) => { if (alive) setTeamMembers(m); }).catch(() => {});
api.teamSettings().then((t) => { if (alive) setFreshTeam(t); }).catch(() => {});
return () => { alive = false; };
}, [reloadFlag]);
// 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条
const BILLS_PER_PAGE = 10;
const [billPage, setBillPage] = useState(1);
// 账单流水筛选(类型 + 成员):传参给后端做全量过滤(R70)。
// 原实现只在前端过滤当前页 10 条,导致「扣费/充值」数据不全、总页数共用全部类型的 count。
const [billType, setBillType] = useState<string>("all");
const [billMember, setBillMember] = useState<string>("all");
const [ledgerRows, setLedgerRows] = useState<Ledger[]>([]);
const [ledgerCount, setLedgerCount] = useState<number>(0);
const [ledgersLoading, setLedgersLoading] = useState(true);
useEffect(() => {
let alive = true;
setLedgersLoading(true);
api.ledgers(billPage, BILLS_PER_PAGE).then((data) => {
api.ledgers(billPage, BILLS_PER_PAGE, {
ledger_type: billType === "all" ? undefined : billType,
user: billMember === "all" ? undefined : billMember
}).then((data) => {
if (!alive) return;
setLedgerRows(data.results);
setLedgerCount(data.count);
}).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); });
return () => { alive = false; };
}, [billPage, reloadFlag]);
}, [billPage, billType, billMember, reloadFlag]);
// row40:跳页输入框 —— 输入页码回车/点「跳转」即钳到 [1,总页数] 并翻页
const [billJump, setBillJump] = useState("");
@@ -171,8 +182,10 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
const balance = Number(billing?.account.balance || 0);
const used = Number(billing?.charged_total || 0);
// 月限额:与团队管理页保持同一来源(team.monthly_credit_limit) · #14
// 优先用团队级月限额;-1 = 不限(显示余额);0/null = 未设置 → 用成员额度累加,再 fallback 余额
const savedMonthlyLimit = team?.monthly_credit_limit == null ? 0 : Number(team.monthly_credit_limit);
// 优先用本页刚拉的最新团队设置,fallback 到 team prop(R72:prop 是 /me/ 快照,改完限额不刷新会陈旧)
// -1 = 不限(显示余额);0/null = 未设置 → 用成员额度累加,再 fallback 余额
const teamData = freshTeam ?? team;
const savedMonthlyLimit = teamData?.monthly_credit_limit == null ? 0 : Number(teamData.monthly_credit_limit);
const memberLimitSum = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
const limit = savedMonthlyLimit === -1 ? balance : (savedMonthlyLimit > 0 ? savedMonthlyLimit : (memberLimitSum || balance));
const left = Math.max(0, limit - used);
@@ -216,28 +229,15 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
const memUsedSum = filteredMembers.reduce((s, m) => s + Number(m.month_charged || 0), 0);
const memFiltered = memRole !== "all";
// ── 账单流水筛选(类型 + 成员)── 后端为服务端分页且无过滤参数(api.ledgers 仅收 page/pageSize),
// 故只能对「当前页」已载入的 10 条做客户端过滤
// 切换筛选条件时跳回第 1 页(#9a);过滤后按实际可见行数重算总页数(#9b)。
const [billType, setBillType] = useState<string>("all");
const [billMember, setBillMember] = useState<string>("all");
const billMemberOptions = useMemo(() => {
const set = new Set<string>();
ledgerRows.forEach((l) => { if (l.user_label) set.add(l.user_label); });
return Array.from(set);
}, [ledgerRows]);
const visibleLedgers = useMemo(
() => ledgerRows.filter((l) =>
(billType === "all" || l.ledger_type === billType) &&
(billMember === "all" || (l.user_label || "系统") === billMember)
),
[ledgerRows, billType, billMember]
// ── 账单流水筛选辅助 ── 筛选本体在上方 useEffect 走后端全量过滤(R70)。
// 成员下拉选项取团队成员表(原先取自当前页流水 user_label,只能看到本页出现过的人);value 传 user id 给后端
const billMemberOptions = useMemo(
() => teamMembers.map((m) => ({ id: m.user.id, label: m.user.username || m.user.email || "成员" })),
[teamMembers]
);
const billFiltered = billType !== "all" || billMember !== "all";
// 有筛选时按当前页已过滤的可见行数重算总页数,避免总页数用全量致翻到空页(#9b)
const billTotalPages = billFiltered
? Math.max(1, Math.ceil(visibleLedgers.length / BILLS_PER_PAGE))
: Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
// 服务端过滤后 count 即当前筛选自己的总数,总页数随筛选变化(R70)
const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
const safeBillPage = Math.min(billPage, billTotalPages);
function gotoBillPage() {
const n = parseInt(billJump, 10);
@@ -418,13 +418,13 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
</select>
<select value={billMember} onChange={(e) => { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选">
<option value="all"></option>
{billMemberOptions.map((m) => <option key={m} value={m}>{m}</option>)}
{billMemberOptions.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
{billFiltered && (
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); setBillPage(1); }}></button>
)}
<span className="spacer"></span>
<span className="ct"> <b>{visibleLedgers.length}</b> · {ledgerCount} </span>
<span className="ct"> <b>{ledgerRows.length}</b> · {ledgerCount} </span>
</div>
<div className="billing-table-wrap">
<table className="billing-table">
@@ -432,9 +432,9 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
<tbody>
{ledgersLoading && ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 加载中…</td></tr>
) : visibleLedgers.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "// 本页无匹配筛选条件的账单" : "// 暂无账单流水"}</td></tr>
) : visibleLedgers.map((l) => (
) : ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "// 无匹配筛选条件的账单" : "// 暂无账单流水"}</td></tr>
) : ledgerRows.map((l) => (
<tr key={l.id}>
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
<td>{ledgerTypeLabel(l.ledger_type)}</td>
+180 -283
View File
@@ -27,11 +27,11 @@ import {
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product } from "../types";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import { api } from "../api";
import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading";
import { MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import { useViewMode } from "../components/use-view-mode";
import type { Page } from "./route-config";
@@ -214,36 +214,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return () => document.removeEventListener("click", close);
}, [openChip]);
// row38:任务中心批次弹窗 —— 每张生成图都可「加入资产库 / 取消加入」,真源是后端 Asset.in_library
// 乐观更新 batchImgs(弹窗)+ assets(列表 cover 用),失败回滚。资产生成时已落库扣费,此口只控是否进资产库列表、不二次扣费。
const [libBusy, setLibBusy] = useState(false);
function patchInLib(ids: Set<string>, next: boolean) {
setBatchImgs((prev) => prev.map((a) => (ids.has(a.id) ? { ...a, in_library: next } : a)));
setAssets((prev) => prev.map((a) => (ids.has(a.id) ? { ...a, in_library: next } : a)));
}
async function toggleBatchImgLibrary(asset: Asset) {
if (!asset.id || libBusy) return;
const next = !asset.in_library;
const ids = new Set([asset.id]);
patchInLib(ids, next);
try { await api.setAssetLibrary(asset.id, next); }
catch { patchInLib(ids, !next); }
}
// 一键:有未入库 → 全部加入;已全部入库 → 全部移出
const batchWithId = batchImgs.filter((a) => a.id);
const allInLib = batchWithId.length > 0 && batchWithId.every((a) => a.in_library);
async function toggleAllBatchLibrary() {
if (!batchWithId.length || libBusy) return;
const next = !allInLib;
const targets = batchWithId.filter((a) => a.in_library !== next).map((a) => a.id);
if (!targets.length) return;
const ids = new Set(targets);
setLibBusy(true);
patchInLib(ids, next);
try { await Promise.all(targets.map((id) => api.setAssetLibrary(id, next))); }
catch { patchInLib(ids, !next); }
finally { setLibBusy(false); }
}
// R109:生成图已「自动入资产库」,任务中心批次弹窗不再提供加入/取消入库操作(纯查看 + 放大)
const typeOptions = Array.from(new Set(taskBatches.map((b) => b.label).filter(Boolean)));
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
@@ -484,13 +455,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
<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 }} />
{/* row38:一键全部加入资产库 / 全部移出(整批切换) */}
{batchWithId.length > 0 && (
<button className={`btn btn-sm${allInLib ? "" : " btn-primary"}`} type="button" disabled={libBusy} style={{ marginRight: 8 }} onClick={toggleAllBatchLibrary}>
<Bookmark size={13} />
{allInLib ? "全部移出资产库" : "一键全部加入资产库"}
</button>
)}
{/* R109:成图自动入资产库,批次弹窗不再放「一键加入/移出资产库」按钮 */}
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
</div>
<div className="modal-b">
@@ -502,30 +467,13 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
<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 || "";
const inLib = !!a.in_library;
return (
<div className={`gen-image${inLib && u ? " adopted" : ""}`} key={a.id}>
<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>
)}
{/* row38:左上「已入库」绿标 + 右下角加入/取消按钮(单图粒度) */}
{u && inLib && (
<span className="gen-adopt-badge"><Check size={11} /></span>
)}
{u && (
<button
type="button"
className={`tc-lib-btn${inLib ? " on" : ""}`}
title={inLib ? "取消加入资产库" : "加入资产库"}
disabled={libBusy}
onClick={() => toggleBatchImgLibrary(a)}
>
<Bookmark size={13} />
{inLib ? "取消" : "加入资产库"}
</button>
)}
</div>
);
})}
@@ -641,8 +589,8 @@ function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: s
);
}
/* / = :;
31() / 32( + + /) / 33() */
/* / = :;
31() / 32( + ) / R100() / R109() */
type GenBatch = {
id: string;
prompt: string;
@@ -650,8 +598,6 @@ type GenBatch = {
count: number;
status: "generating" | "done" | "failed";
results: Asset[];
/** 批次级:是否已"加入资产库"(可来回切) */
adopted: boolean;
ts: number;
/** 该批次归属的商品(用于按商品分组,各商品一个导航头) */
productId?: string;
@@ -664,10 +610,15 @@ type GenBatch = {
platformIds?: string[];
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
refs?: { name: string; url: string }[];
/** 该批次已提交的生图任务 id:落盘后切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
pendingIds?: string[];
};
/* R100:后端 platform_id(douyin/taobao…) → 前端平台 key(dy/tb…),恢复批次时还原平台分组 */
const PLATFORM_KEY_MAP: Record<string, string> = Object.fromEntries(
Object.entries(PLATFORM_ID_MAP).map(([key, canonical]) => [canonical, key])
);
export function ImageWorkbenchPage({
mode,
products,
@@ -715,7 +666,6 @@ export function ImageWorkbenchPage({
const [genModel, setGenModel] = useState<string>(() => {
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
});
const [genModelOpen, setGenModelOpen] = useState(false);
useEffect(() => { try { localStorage.setItem(GEN_MODEL_KEY, genModel); } catch { /* 忽略 */ } }, [genModel]);
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
@@ -872,22 +822,9 @@ export function ImageWorkbenchPage({
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
/* (33): ai-tools key, App `airshelf:imgwb:{mode}`
;App ,"刷新时仍在跑"() */
const batchKey = `airshelf:imgwb:batches:${mode}`;
function persistBatches(list: GenBatch[]) {
try {
// 已出图/失败的批次照常落盘;仍在跑(generating)但已拿到 pendingIds 的批次也落盘(保留 generating + pendingIds),
// 这样切走再回来每一批都能按各自的 pendingIds 续轮询,不再「数量少 / 只恢复一批」(PMC#5/#10)。
// 还没拿到 pendingIds 的占位批次(刚点、尚未提交成功)不落盘,避免恢复出一个永远空转的壳。
const slim = list
.filter((b) => b.status !== "generating" || (b.pendingIds && b.pendingIds.length))
.map((b) => ({ ...b, refs: undefined })); // refs 含本地 blob url,不落盘
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
} catch {
/* localStorage 不可用时静默降级 */
}
}
/* R100: localStorage (1 =
)/( workbenchTasks effect),
image ( AITask) */
/* ════════ 图片创作「对话」实体的增删改查 + 历史回填 ════════ */
@@ -903,27 +840,67 @@ export function ImageWorkbenchPage({
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));
// R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []);
const allTerminal = live.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,
prompt: live[0]?.prompt || "",
ratio: live[0]?.ratio || meta.ratio,
count: live.length,
status,
results: assets,
adopted: withId.length > 0 && withId.every((a) => a.in_library),
ts: new Date(list[0]?.created_at || Date.now()).getTime(),
ts: new Date(live[0]?.created_at || Date.now()).getTime(),
// 该批用过的参考图(后端按 reference_image_ids 解析回 {name,url}),切换/刷新后仍可见
refs: (list[0]?.reference_images || []).length ? list[0].reference_images : undefined,
refs: (live[0]?.reference_images || []).length ? live[0].reference_images : undefined,
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
return result.sort((a, b) => a.ts - b.ts);
}
/* R100: ( batchesFromConvTasks ,//)
pendingIds,;(R109 ) */
function batchesFromWorkbenchTasks(tasks: WorkbenchTask[]): GenBatch[] {
const groups = new Map<string, WorkbenchTask[]>();
for (const t of tasks) {
const key = t.batch_id || t.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 live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []);
const hasRunning = live.some((t) => !TERMINAL.has(t.status));
const status: GenBatch["status"] = hasRunning ? "generating" : assets.length ? "done" : "failed";
const first = live[0];
const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : "";
result.push({
id: key,
prompt: first.prompt || "",
ratio: first.ratio || meta.ratio,
count: live.length,
status,
results: assets,
ts: new Date(first.created_at || Date.now()).getTime(),
productId: first.product_id || undefined,
modelId: first.model_id || undefined,
platformIds: platformKey ? [platformKey] : undefined,
// 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results,
// 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。
pendingIds: hasRunning ? live.map((t) => t.id) : undefined,
});
}
return result.sort((a, b) => a.ts - b.ts);
}
// 拉某对话的历史批次并回显;非终态批次继续轮询补齐
const loadConvBatches = useCallback(async (convId: string) => {
try {
@@ -1039,18 +1016,10 @@ export function ImageWorkbenchPage({
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
if (opts.reuseBatchId) {
// 全批重跑:把原卡复位为「生成中」,清掉旧(失败/旧)结果,保留其归属与参数
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b));
persistBatches(next);
return next;
});
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b)));
} else if (opts.appendToBatchId) {
// 单图重跑:原卡转「生成中」补图,保留已有好图
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b));
persistBatches(next);
return next;
});
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b)));
} else {
const newBatch: GenBatch = {
id: batchId,
@@ -1059,7 +1028,6 @@ export function ImageWorkbenchPage({
count: opts.count,
status: "generating",
results: [],
adopted: false,
ts: Date.now(),
productId: opts.productId,
productTitle: opts.productTitle,
@@ -1091,37 +1059,29 @@ export function ImageWorkbenchPage({
referenceImageIds = ids.filter((x): x is string => !!x);
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10)
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
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, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) });
onSubmitted: (taskIds) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b))) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
setActiveConvId(convId);
void loadConversations(false);
}
setBatches((prev) => {
const next = prev.map((b) => {
if (b.id !== batchId) return b;
const newAssets = result?.assets || [];
// 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果
if (opts.appendToBatchId) {
const seen = new Set(b.results.map((a) => a.id));
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))];
return { ...b, status: merged.length ? ("done" as const) : ("failed" as const), results: merged };
}
return { ...b, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets };
});
persistBatches(next);
return next;
});
setBatches((prev) => prev.map((b) => {
if (b.id !== batchId) return b;
const newAssets = result?.assets || [];
// 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果
if (opts.appendToBatchId) {
const seen = new Set(b.results.map((a) => a.id));
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))];
return { ...b, status: merged.length ? ("done" as const) : ("failed" as const), results: merged, pendingIds: undefined };
}
return { ...b, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
}));
} catch {
setBatches((prev) => {
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
const next = prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b));
persistBatches(next);
return next;
});
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b)));
}
}
@@ -1185,68 +1145,29 @@ export function ImageWorkbenchPage({
});
}
/* 删除整个批次(行32③:批次气泡"删除当前批次") */
function removeBatch(id: string) {
setBatches((prev) => {
const next = prev.filter((b) => b.id !== id);
persistBatches(next);
return next;
});
}
/* 删除批次内单张图(行32③:单图气泡"删除") */
function removeImageFromBatch(batchId: string, assetId: string) {
setBatches((prev) => {
const next = prev.map((b) =>
b.id === batchId ? { ...b, results: b.results.filter((a) => a.id !== assetId) } : b
);
persistBatches(next);
return next;
});
}
/* in_library + +
Asset.in_library:加入 ; batch.adopted */
function applyLibraryState(batchId: string, assetIds: string[], next: boolean) {
const ids = new Set(assetIds.filter(Boolean));
if (!ids.size) return;
const sync = (b: GenBatch, want: (a: Asset) => boolean): GenBatch => {
const results = b.results.map((a) => (ids.has(a.id) ? { ...a, in_library: want(a) } : a));
const withId = results.filter((a) => a.id);
return { ...b, results, adopted: withId.length > 0 && withId.every((a) => a.in_library) };
};
setBatches((prev) => {
const updated = prev.map((b) => (b.id === batchId ? sync(b, () => next) : b));
persistBatches(updated);
return updated;
});
void Promise.all([...ids].map((id) => api.setAssetLibrary(id, next))).catch(() => {
// 写库失败:回滚到改前状态(按 id 翻回 !next)
setBatches((prev) => {
const reverted = prev.map((b) => (b.id === batchId ? sync(b, () => !next) : b));
persistBatches(reverted);
return reverted;
});
});
}
/* 加入资产库 / 取消加入(批次级:全部已入库 → 整批移出,否则整批加入) */
function toggleAdoptBatch(batchId: string) {
const batch = batches.find((b) => b.id === batchId);
/* R108/R109:= () ,
"复活"(ConfirmModal),
confirmDel.assetId = ; = () */
const [confirmDel, setConfirmDel] = useState<{ batchId: string; assetId?: string } | null>(null);
function doConfirmedDelete() {
const target = confirmDel;
setConfirmDel(null);
if (!target) return;
const batch = batches.find((b) => b.id === target.batchId);
if (!batch) return;
const withId = batch.results.filter((a) => a.id);
if (!withId.length) return;
const next = !withId.every((a) => a.in_library);
applyLibraryState(batchId, withId.map((a) => a.id), next);
}
/* 加入资产库 / 取消(单图级:只改被点的那一张) */
function toggleAdoptImage(batchId: string, assetId: string) {
if (!assetId) return;
const asset = batches.find((b) => b.id === batchId)?.results.find((a) => a.id === assetId);
if (!asset) return;
const next = !asset.in_library;
applyLibraryState(batchId, [assetId], next);
const ids = (target.assetId ? [target.assetId] : batch.results.map((a) => a.id)).filter(Boolean);
ids.forEach((id) => { void api.deleteAsset(id).catch(() => undefined); });
if (target.assetId) {
// 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除
setBatches((prev) => prev.flatMap((b) => {
if (b.id !== target.batchId) return [b];
const results = b.results.filter((a) => a.id !== target.assetId);
if (!results.length && b.status === "done") return [];
return [{ ...b, results, count: Math.max(1, b.count - 1) }];
}));
} else {
setBatches((prev) => prev.filter((b) => b.id !== target.batchId));
}
}
/* : Model (;)
@@ -1280,49 +1201,39 @@ export function ImageWorkbenchPage({
});
}
/* (33): ai-tools ;
App `airshelf:imgwb:{mode}`(), */
/* R100:() localStorage 1
/, / mode+product
; pendingIds,() */
useEffect(() => {
let cancelled = false;
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,避免双源打架
if (mode === "image") return;
// 模特/平台套图:从多批次残留恢复「全部」批次 —— 已出图/失败的直接回显;仍在跑(带 pendingIds)的
// 恢复成 generating 并各自按本批 pendingIds 续轮询。这样切走再回来,多批/多平台都在,不再「数量少 / 分类丢」(PMC#5/#10)。
try {
const raw = localStorage.getItem(batchKey);
if (raw) {
const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number };
if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) {
const all = saved.batches || [];
if (all.length) {
setBatches(all);
if (onResume) {
for (const b of all) {
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
onResume(mode, b.pendingIds).then((res) => {
if (cancelled) return;
setBatches((prev) => {
const next = prev.map((x) =>
x.id === b.id ? { ...x, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || x.results } : x
);
persistBatches(next);
return next;
});
});
}
}
if (!productId) { setBatches([]); return; }
let cancelled = false;
api.workbenchTasks(mode, productId)
.then((res) => {
if (cancelled) return;
const next = batchesFromWorkbenchTasks(res.tasks || []);
// 竞态保护:拉取在途时用户又提交了新批次(本地 id 以 b- 开头、还在生成中)→ 合并保留,别被整体替换冲掉
setBatches((prev) => [...next, ...prev.filter((b) => b.id.startsWith("b-") && b.status === "generating")]);
if (onResume) {
for (const b of next) {
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
onResume(mode, b.pendingIds).then((r) => {
if (cancelled || !r?.assets) return;
setBatches((prev) => prev.map((x) =>
x.id === b.id ? { ...x, status: "done" as const, results: r.assets, pendingIds: undefined } : x
));
});
}
}
}
} catch {
/* 残留解析失败则忽略 */
}
})
.catch(() => { /* 拉取失败(后端未更新/网络抖动):保留当前内存批次,不清空 */ });
return () => {
cancelled = true;
};
// 仅在挂载/切换 mode 时恢复一次
// 仅在挂载 / 切 mode / 切商品时恢复(不依赖 onResume 身份,避免反复重拉)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
}, [mode, productId]);
// YYX#3:工作台数据按商品隔离 —— 模特/平台模式右侧结果只展示当前选中商品的批次
// (旧批次无 productId 则全局可见,兜底);image 模式按对话维度,沿用全部 batches。
@@ -1342,8 +1253,8 @@ export function ImageWorkbenchPage({
const hasResults = productBatches.length > 0;
/* · §4.18 gen-card
.gen-image : .gen-image-actions( / / ·)
.gen-image-feedback ( 1.5s) 绿 */
.gen-image : .gen-image-actions( / / )
+ R109:成图自动入资产库,/ */
function renderBatchGrid(batch: GenBatch) {
const generating = batch.status === "generating";
const n = batch.results.length || batch.count;
@@ -1360,12 +1271,11 @@ export function ImageWorkbenchPage({
const asset = batch.results[index];
const url = asset?.files?.[0]?.preview_url;
const assetId = asset?.id || "";
const inLib = !!asset?.in_library;
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
const key = assetId || `ph-${index}`;
const cellKey = `${batch.id}:${assetId || key}`;
return (
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}${inLib && url ? " adopted" : ""}`} key={key}>
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}`} key={key}>
{url ? (
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
) : (
@@ -1389,14 +1299,7 @@ export function ImageWorkbenchPage({
)}
</div>
)}
{/* ③ 左上「已入库」绿角标(该图已加入资产库时常驻) */}
{url && inLib && (
<span className="gen-adopt-badge">
<Check size={11} />
</span>
)}
{/* YYX#20:去掉「加入资产库」后的中央遮罩反馈,只保留左上角「已入库」标签 */}
{/* R109:成图自动入资产库 —— 去掉「已入库」角标与加入/取消入口 */}
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
{url && (
<div className="gen-image-actions">
@@ -1410,13 +1313,7 @@ export function ImageWorkbenchPage({
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
<MoreHorizontal size={14} />
</button>
{/* YYX#20 :(),;
, */}
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
<Bookmark size={13} />
{inLib ? "取消加入资产库" : "加入资产库"}
</button>
{/* row41:仅图片创作(mode=image)显示「加入模特库」;模特上身图/平台套图不显示 */}
{mode === "image" && (
<button type="button" className="gen-bubble-item" disabled={enrolledModelIds.has(assetId) || enrollingModelId === assetId} onClick={() => { setOpenMore(""); enrollModelFromImage(assetId); }}>
@@ -1424,7 +1321,8 @@ export function ImageWorkbenchPage({
{enrolledModelIds.has(assetId) ? "已加入模特库" : (enrollingModelId === assetId ? "加入中…" : "加入模特库")}
</button>
)}
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
{/* R108/R109:删除 = 真删资产(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); setConfirmDel({ batchId: batch.id, assetId }); }}>
<Trash2 size={13} />
</button>
@@ -1461,27 +1359,22 @@ export function ImageWorkbenchPage({
prompt={batch.prompt}
count={batch.count}
statusText={generating ? "生成中" : failed ? "失败" : "已完成"}
adoptedCount={!generating && !failed ? batch.results.filter((a) => a.in_library).length : 0}
/>
<div className="gen-card gen-batch-card">
{renderBatchGrid(batch)}
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
{/* 行30/32:批次底部操作行(R109:成图自动入资产库,去掉「加入资产库」按钮) */}
<div className="gen-batch-actions">
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => rerunBatch(batch)}>
<RefreshCw size={13} />
</button>
<button className="btn btn-sm" type="button" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
{batch.adopted ? <X size={13} /> : <Check size={13} />}
{batch.adopted ? "取消加入资产库" : "加入资产库"}
</button>
{/* YYX#20:批次底部「加入资产库」已是上面的独立按钮,这里气泡只留「删除当前批次」,去掉重复入口 */}
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
</button>
@@ -1651,16 +1544,14 @@ export function ImageWorkbenchPage({
<RefreshCw size={13} />
</button>
<button className="btn btn-sm" type="button" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
{batch.adopted ? <X size={13} /> : <Check size={13} />}
{batch.adopted ? "取消加入资产库" : "加入资产库"}
</button>
{/* R109:成图自动入资产库,去掉「加入资产库」按钮 */}
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
</button>
@@ -1743,6 +1634,17 @@ export function ImageWorkbenchPage({
</div>
</div>
</section>
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
<ConfirmModal
open={!!confirmDel}
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
icon={<Trash2 size={16} />}
detail="删除的图片将回收到垃圾桶,是否需要删除?"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
/>
</div>
);
}
@@ -2093,29 +1995,22 @@ export function ImageWorkbenchPage({
)}
<div className="iw-sub">
<div className="iw-sub-h">// 提示词 · 描述你想要的画面</div>
{/* YYX#7:提示词框加大;YYX#10:生图模型选择作胶囊嵌在框内靠左置底 */}
{/* YYX#7/R83: , textarea( 4-5 ,,)+ ,;
YYX#10/R86:生图模型选择作胶囊嵌在框内工具条靠左置底( chat Pill,) */}
<div className="iw-prompt-box">
<textarea
className="textarea iw-prompt-text"
className="iw-prompt-text"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="iw-prompt-bar">
<div className={`chip-wrap iw-model-pill${genModelOpen ? " open" : ""}`} data-filter="genmodel">
<button className="chip" type="button" title="生图模型" onClick={() => setGenModelOpen((v) => !v)}>
<Sparkles size={12} />
<span className="chip-label">{GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}</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>
<div className="chip-menu">
{GEN_MODEL_OPTIONS.map((o) => (
<div className={`mi${genModel === o.value ? " selected" : ""}`} key={o.value} role="button" tabIndex={0} onClick={() => { setGenModel(o.value); setGenModelOpen(false); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{o.label}
</div>
))}
</div>
</div>
<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}
/>
</div>
</div>
</div>
@@ -2222,12 +2117,24 @@ export function ImageWorkbenchPage({
navigate={navigate}
/>
)}
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
<ConfirmModal
open={!!confirmDel}
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
icon={<Trash2 size={16} />}
detail="删除的图片将回收到垃圾桶,是否需要删除?"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
/>
</div>
);
}
/* YYX#18:结果卡精简头部 —— 提示词单行折叠(超出给「展开/收起」)+ 灰色张数文案 */
function BatchHeader({ prompt, count, statusText, adoptedCount }: { prompt: string; count: number; statusText: string; adoptedCount: number }) {
function BatchHeader({ prompt, count, statusText }: { prompt: string; count: number; statusText: string }) {
const [expanded, setExpanded] = useState(false);
const [overflow, setOverflow] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -2244,7 +2151,7 @@ function BatchHeader({ prompt, count, statusText, adoptedCount }: { prompt: stri
</button>
)}
<div className="ibh-count">
{count} · {statusText}{adoptedCount > 0 ? ` · ${adoptedCount} 张已入库` : ""}
{count} · {statusText}
</div>
</div>
);
@@ -2622,9 +2529,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2649,9 +2554,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2748,9 +2651,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2773,9 +2674,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2825,9 +2724,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
+26 -4
View File
@@ -541,13 +541,21 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
setReloadFlag((f) => f + 1);
}
// 删除(单 / 批):乐观隐藏 → 并发删 → 刷新计数/列表
// 删除(单 / 批):乐观隐藏 → 并发删 → 刷新计数/列表。后端为回收站软删(R108),可恢复。
async function doDelete() {
const ids = confirmIds || [];
setConfirmIds(null);
if (!ids.length || !onDelete) return;
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; });
setSelected(new Set());
// R108:批次弹窗内单张删除 —— 同步从打开中的批次里摘掉;删空则关掉弹窗
setOpenBatch((prev) => {
if (!prev) return prev;
const items = prev.items.filter((a) => !ids.includes(a.id));
if (!items.length) return null;
const cover = items[0].files?.find((f) => f.is_primary)?.preview_url || items[0].files?.[0]?.preview_url || prev.cover;
return { ...prev, items, count: items.length, cover };
});
try {
await Promise.all(ids.map((id) => onDelete(id)));
setReloadFlag((f) => f + 1);
@@ -835,10 +843,24 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
{openBatch.items.map((a, i) => {
const cover = a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
return (
<button className="pack-clip as-btn" type="button" key={a.id} onClick={() => { setOpenBatch(null); setDetail(a); }} title="查看详情">
// R108:图片集内单张悬浮删除 icon(嵌套按钮非法 → 外层改 div[role=button])
<div
className="pack-clip as-btn"
role="button"
tabIndex={0}
key={a.id}
onClick={() => { setOpenBatch(null); setDetail(a); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(null); setDetail(a); } }}
title="查看详情"
>
{onDelete && (
<button className="card-del-btn" type="button" title="删除这张图片" onClick={(event) => { event.stopPropagation(); setConfirmIds([a.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
{cover ? <img src={cover} alt={a.name} loading="lazy" /> : <span className="pack-clip-ph ph-frame"></span>}
<div className="pack-clip-name mono"> {i + 1} </div>
</button>
</div>
);
})}
</div>
@@ -872,7 +894,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
title="删除资产"
subtitle="// DELETE ASSET"
icon={<Trash2 size={16} />}
detail={`确定删除选中 ${confirmIds?.length || 0} 个资产?该操作不可撤销。`}
detail={`选中 ${confirmIds?.length || 0} 个资产。删除的图片将回收到垃圾桶,是否需要删除?`}
confirmText="删除"
onCancel={() => setConfirmIds(null)}
onConfirm={doDelete}
+24 -25
View File
@@ -845,15 +845,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const [cat, setCat] = useState(realCat);
const [target, setTarget] = useState(realTarget);
const [points, setPoints] = useState<string[]>(realBullets);
const [pointDraft, setPointDraft] = useState("");
function addPoint(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return;
event.preventDefault();
const value = pointDraft.trim();
if (!value) return;
setPoints((list) => [...list, value]);
setPointDraft("");
// R104:显式「添加卖点」按钮追加一条空的可编辑卖点行(行内回车同样追加),
// 替代原来嵌在列表里的「+ 输入框 · 回车确认」隐晦入口。
function addPointRow() {
setPoints((list) => [...list, ""]);
}
function removePoint(index: number) {
setPoints((list) => list.filter((_, position) => position !== index));
@@ -867,8 +863,9 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
title: name,
category: cat,
target_audience: target,
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id
selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index })) as Product["selling_points"]
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id;
// 过滤空行(点了「添加卖点」还没填的行),否则后端 title 非空校验会退 400
selling_points: points.map((item) => item.trim()).filter(Boolean).map((item, index) => ({ title: item, detail: item, sort_order: index })) as Product["selling_points"]
});
setEditing(false);
setActionsLockH(null);
@@ -878,7 +875,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
setCat(realCat);
setTarget(realTarget);
setPoints(realBullets);
setPointDraft("");
setEditing(false);
setActionsLockH(null);
}
@@ -1013,17 +1009,18 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
{points.map((bullet, index) => (
<li className="bl-item" key={index}>
<span className="num">{index + 1}</span>
<input className="bl-input" value={bullet} onChange={(event) => updatePoint(index, event.target.value)} placeholder="卖点描述" />
<input className="bl-input" value={bullet} onChange={(event) => updatePoint(index, event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addPointRow(); } }} placeholder="卖点描述" />
<button className="bl-x" type="button" onClick={() => removePoint(index)} aria-label="删除卖点">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</li>
))}
<li className="bl-add">
<span className="num">+</span>
<input className="bl-input" value={pointDraft} onChange={(event) => setPointDraft(event.target.value)} onKeyDown={addPoint} placeholder="添加新卖点 · 回车确认" />
</li>
</ul>
{/* R104:显式「添加卖点」按钮(独立小按钮,不嵌在卖点输入框内);仅编辑态可见(.v-edit) */}
<button type="button" className="v-edit bl-add-btn" onClick={addPointRow}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
</div>
</div>
</div>
@@ -1035,18 +1032,20 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div>
<div className="grid" id="ov-images-grid">
{visibleProductImages.map((image) => {
// 编辑态下每张有 ProductImage 关联的图都给删除按钮(hover 出垃圾桶),不再因
// 「只剩一张可删」而整张隐藏删除入口 —— 否则单图商品 / 删到最后一张时悬停看不到垃圾桶,
// 体感像「删除坏了,只能放大」。最后一张的「至少保留一张」约束由后端兜底(删时退 400),
// 这里不再前置静默禁用,改成点了真调接口、失败回滚 + 弹后端原文(如「至少保留一张图」)。
const canDelete = editing && Boolean(image.imageId) && Boolean(onDeleteImage);
// 编辑态下每张图都给删除按钮(hover 出垃圾桶),不再因「只剩一张可删」而整张隐藏删除入口:
// - 有 ProductImage 关联的图 → DELETE images/{imageId}(最后一张由后端退 400 兜底,失败回滚 + 弹后端原文);
// - 仅封面图(cover_asset 无 ProductImage 关联:采用三视图为封面 / 历史悬空封面)→ PATCH cover_asset=null 摘掉封面。
// 此前这种图完全没有删除入口,悬停只能放大,体感「删除坏了」(YYX R6)。
const canDelete = editing && (image.imageId ? Boolean(onDeleteImage) : true);
const handleDelete = async (event: { stopPropagation: () => void }) => {
event.stopPropagation();
if (!canDelete || !image.imageId || !onDeleteImage) return;
// 乐观隐藏:立刻从本地列表移除;真正持久化由后端 DELETE 完成,
// 成功后父组件刷新会把该图从 product.images 抹掉(reconcile effect 同步清理乐观标记)。
if (!canDelete) return;
// 乐观隐藏:立刻从本地列表移除;真正持久化由后端完成,
// 成功后父组件刷新会把该图从 product.images / cover_asset 抹掉(reconcile effect 同步清理乐观标记)。
setDeletedImageKeys((prev) => new Set([...prev, image.key]));
const result = await onDeleteImage(image.imageId);
const result = image.imageId && onDeleteImage
? await onDeleteImage(image.imageId)
: await onUpdate({ cover_asset: null });
if (result === null || result === undefined) {
// action 返回 null/undefined 表示接口调用失败(已弹错误 toast,如「至少保留一张图」),
// 回滚乐观隐藏 —— 图重新出现,不会出现「弹了删除成功但其实没删」的错觉。
+7 -1
View File
@@ -101,6 +101,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
let alive = true;
api.teamMembers().then((m) => { if (alive) setMembers(m); }).catch(() => {});
api.allNotifications().then((n) => { if (alive) { setNotifications(n.results); setNotificationTotal(n.count); } }).catch(() => {});
// 月限额自取最新值:team prop 来自登录时 /me/ 快照,改完限额换页再回来会显示旧值(R72)
api.teamSettings().then((t) => {
if (alive) setSavedMonthlyLimit(t.monthly_credit_limit == null ? 0 : Number(t.monthly_credit_limit));
}).catch(() => {});
return () => { alive = false; };
}, [reloadFlag]);
const reloadTeam = () => setReloadFlag((n) => n + 1);
@@ -156,7 +160,9 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
setSavedMonthlyLimit(v);
setLimitBusy(true);
try {
await api.updateTeamSettings({ monthly_credit_limit: v });
const updated = await api.updateTeamSettings({ monthly_credit_limit: v });
// 以后端回传的落库值为准(消费页同源读 teamSettings,保证两页立即一致 · R72)
setSavedMonthlyLimit(updated.monthly_credit_limit == null ? 0 : Number(updated.monthly_credit_limit));
setModal("");
} catch {
setSavedMonthlyLimit(prev); // 落库失败回滚,避免显示假成功
+114 -35
View File
@@ -2,26 +2,45 @@ import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import { api } from "../api";
import type { NavigateFn } from "./route-config";
import type { Product } from "../types";
import type { Asset, Product } from "../types";
import "../trash-page.css";
const coverOf = (p: Product): string => p.cover_preview_url || p.images?.find((i) => i.preview_url)?.preview_url || "";
// 资产缩略图:主文件直链优先,任一带 preview_url 的文件兜底
const assetCoverOf = (a: Asset): string =>
a.files?.find((f) => f.is_primary && f.preview_url)?.preview_url || a.files?.find((f) => f.preview_url)?.preview_url || "";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// mono 时间戳(设计规范 // 05.22 式):软删走 update updated_at → updated_at ≈ 删除时间
const dateOf = (iso?: string): string => {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
};
// 垃圾桶:已软删(status=archived)的商品 · 可恢复 / 可彻底删除(不可恢复)
const ASSET_TYPE_LABEL: Record<string, string> = { image: "图片", video: "视频", audio: "音频" };
// 垃圾桶:已软删商品(status=archived)+ 已软删资产(is_deleted,R108)· 均可恢复 / 可彻底删除(不可恢复)
export function TrashPage({ onRestore, onPurge }: {
navigate: NavigateFn;
onRestore: (id: string) => Promise<unknown> | void;
onPurge: (id: string) => Promise<unknown> | void;
}) {
const [items, setItems] = useState<Product[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [errText, setErrText] = useState("");
useEffect(() => {
let alive = true;
api.productsTrash().then((r) => { if (alive) setItems(r.results); }).catch(() => { if (alive) setItems([]); }).finally(() => { if (alive) setLoading(false); });
void Promise.allSettled([api.productsTrash(), api.assetsTrash()]).then(([p, a]) => {
if (!alive) return;
setItems(p.status === "fulfilled" ? p.value.results : []);
setAssets(a.status === "fulfilled" ? a.value.results : []);
setLoading(false);
});
return () => { alive = false; };
}, []);
@@ -35,6 +54,21 @@ export function TrashPage({ onRestore, onPurge }: {
try { await onPurge(id); setItems((list) => list.filter((p) => p.id !== id)); setConfirmId(null); }
finally { setBusyId(null); }
}
// 资产的恢复/彻底删除直接走 api(商品那对经 App 的 action 包装,只发商品口)。失败不移行、如实亮错误。
async function restoreAsset(id: string) {
setBusyId(id);
setErrText("");
try { await api.restoreAsset(id); setAssets((list) => list.filter((a) => a.id !== id)); }
catch (error) { setErrText(error instanceof Error ? error.message : "恢复失败"); }
finally { setBusyId(null); }
}
async function purgeAsset(id: string) {
setBusyId(id);
setErrText("");
try { await api.purgeAsset(id); setAssets((list) => list.filter((a) => a.id !== id)); setConfirmId(null); }
catch (error) { setErrText(error instanceof Error ? error.message : "彻底删除失败"); }
finally { setBusyId(null); }
}
return (
<div className="trash-page">
@@ -42,7 +76,7 @@ export function TrashPage({ onRestore, onPurge }: {
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 已删除商品</span>
<span className="mono">// 已删除商品与资产</span>
<span>·</span>
<span>,()</span>
</div>
@@ -51,40 +85,85 @@ export function TrashPage({ onRestore, onPurge }: {
{loading ? (
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中…</span></div>
) : items.length === 0 ? (
) : items.length === 0 && assets.length === 0 ? (
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
) : (
<div className="trash-list">
{items.map((p) => {
const cover = coverOf(p);
const busy = busyId === p.id;
return (
<div className="trash-row" key={p.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame"></span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={p.title}>{p.title}</div>
<div className="trash-sub mono">// {p.category || "未分类"}</div>
</div>
<div className="trash-actions">
{confirmId === p.id ? (
<>
<span className="trash-confirm-txt mono">?</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purge(p.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}></button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(p.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(p.id)}></button>
</>
)}
</div>
<>
{errText && <div className="trash-err mono">// {errText}</div>}
{items.length > 0 && (
<div className="trash-section">
<div className="trash-section-title mono">// 商品 · {items.length}</div>
<div className="trash-list">
{items.map((p) => {
const cover = coverOf(p);
const busy = busyId === p.id;
return (
<div className="trash-row" key={p.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame"></span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={p.title}>{p.title}</div>
<div className="trash-sub mono">// {p.category || "未分类"}</div>
</div>
<div className="trash-actions">
{confirmId === p.id ? (
<>
<span className="trash-confirm-txt mono">?</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purge(p.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}></button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(p.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(p.id)}></button>
</>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
)}
{assets.length > 0 && (
<div className="trash-section">
<div className="trash-section-title mono">// 资产 · {assets.length}</div>
<div className="trash-list">
{assets.map((a) => {
const cover = assetCoverOf(a);
const busy = busyId === a.id;
const deletedAt = dateOf(a.updated_at);
return (
<div className="trash-row" key={a.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame"></span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={a.name}>{a.name}</div>
<div className="trash-sub mono">// {ASSET_TYPE_LABEL[a.asset_type] || a.asset_type}{deletedAt ? ` · 删除于 ${deletedAt}` : ""}</div>
</div>
<div className="trash-actions">
{confirmId === a.id ? (
<>
<span className="trash-confirm-txt mono">?</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purgeAsset(a.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}></button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restoreAsset(a.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(a.id)}></button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
+8 -1
View File
@@ -1,6 +1,13 @@
/* 垃圾桶 · 已删除商品(软删 status=archived)· 仅用 design-restraint token */
/* 垃圾桶 · 已删除商品(软删 status=archived)+ 已删除资产(is_deleted,R108)· 仅用 design-restraint token */
.trash-empty { min-height: 220px; flex-direction: column; gap: 10px; }
/* 分区(商品 / 资产):mono 小标题 + 各自列表 */
.trash-section { margin-bottom: 22px; }
.trash-section:last-child { margin-bottom: 0; }
.trash-section-title { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; margin-bottom: 10px; }
/* 资产恢复/彻底删除失败提示(商品那对经全局 toast,资产口就地亮错误) */
.trash-err { font-family: var(--font-mono); font-size: 12px; color: var(--accent-crimson); letter-spacing: .02em; margin-bottom: 12px; }
.trash-list { display: flex; flex-direction: column; gap: 10px; }
.trash-row {
+18 -1
View File
@@ -232,7 +232,7 @@ export type Asset = {
description: string;
metadata?: Record<string, unknown>;
origin_task?: string | null;
// 是否在「资产库」列表展示:工作台生成的图默认 false(只在工作台),「加入资产库」后置 true
// 是否在「资产库」列表展示:图片生成的成图自动 true(R109);演员立绘暂存 false,「保存人物」后置 true
in_library?: boolean;
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
product?: string | null;
@@ -562,6 +562,23 @@ export type ImageConversation = {
updated_at: string;
};
// 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源),
// 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。
export type WorkbenchTask = {
id: string;
status: string;
error_message: string;
prompt: string;
batch_id: string;
ratio: string;
product_id: string;
model_id: string;
model_entity_id: string;
platform_id: string;
created_at: string;
assets: Asset[];
};
// 切换对话时回填:该对话下每个生图任务及其成图
export type ImageConversationTask = {
id: string;