From aab4d7ea4cc14cead437d411cc9816556245b334 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Thu, 2 Jul 2026 11:30:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B5=8B=E8=AF=95=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E9=81=97=E7=95=99bug=E6=89=B9=E9=87=8F=E4=BF=AE=E5=A4=8D(15?= =?UTF-8?q?=E6=9D=A1:=E5=95=86=E5=93=81=E5=BA=93/=E6=B6=88=E8=B4=B9/?= =?UTF-8?q?=E5=9B=A2=E9=98=9F/=E8=AE=BE=E7=BD=AE/=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=AD=E5=BF=83/=E5=B9=B3=E5=8F=B0=E5=A5=97=E5=9B=BE/?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E5=BA=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- core/backend/apps/adminpanel/tests.py | 11 +- core/backend/apps/ai/services.py | 7 +- core/backend/apps/ai/tests.py | 118 +++++ core/backend/apps/ai/views.py | 73 ++- .../0008_backfill_workbench_in_library.py | 29 ++ core/backend/apps/assets/tests.py | 46 ++ core/backend/apps/assets/views.py | 51 +- core/backend/apps/billing/tests.py | 32 ++ core/backend/apps/billing/views.py | 4 + core/backend/apps/products/tests.py | 21 + core/backend/apps/products/views.py | 16 +- core/frontend/src/ai-tools-page.css | 81 ++- core/frontend/src/api.ts | 32 +- core/frontend/src/components/app-shell.tsx | 2 +- .../src/components/product-create-drawer.tsx | 17 +- core/frontend/src/library-page.css | 4 + core/frontend/src/messages-page.css | 13 + core/frontend/src/product-create-page.css | 28 +- core/frontend/src/product-detail-page.css | 13 + core/frontend/src/products-page.css | 17 +- core/frontend/src/routes/account.tsx | 58 +-- core/frontend/src/routes/ai-tools.tsx | 463 +++++++----------- core/frontend/src/routes/library.tsx | 30 +- core/frontend/src/routes/products.tsx | 49 +- core/frontend/src/routes/team.tsx | 8 +- core/frontend/src/routes/trash.tsx | 149 ++++-- core/frontend/src/trash-page.css | 9 +- core/frontend/src/types.ts | 19 +- 28 files changed, 924 insertions(+), 476 deletions(-) create mode 100644 core/backend/apps/assets/migrations/0008_backfill_workbench_in_library.py diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 1e676cf..7e1beb0 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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: diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index ba290d3..64f69b3 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 重试二次扣费) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index b74ce86..8863556 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -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/ 只统计 mode∈model/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) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index cf0e886..fdf740c 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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()) diff --git a/core/backend/apps/assets/migrations/0008_backfill_workbench_in_library.py b/core/backend/apps/assets/migrations/0008_backfill_workbench_in_library.py new file mode 100644 index 0000000..9287f7b --- /dev/null +++ b/core/backend/apps/assets/migrations/0008_backfill_workbench_in_library.py @@ -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), + ] diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 6c7b8df..4e7bee1 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -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 的真实视频资产)。""" diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index bcba4e6..3f59a67 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -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_(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): """视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源: diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index 4b55f7f..3bf186c 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -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 拦截。""" diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py index 6b1fec3..bc69dc0 100644 --- a/core/backend/apps/billing/views.py +++ b/core/backend/apps/billing/views.py @@ -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))) diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index c0da726..172b329 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -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) # 封面不受影响 diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index 03c862f..fc966e8 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -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) diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 0991e2d..c84c03e 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -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 { diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index d3b951a..8a2284e 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -154,6 +154,16 @@ export const api = { deleteAsset(id: string) { return request(`/api/assets/${id}/`, { method: "DELETE" }); }, + // R108 资产垃圾桶(与商品垃圾桶同构):已软删资产列表 / 恢复回资产库 / 彻底删除(不可恢复) + assetsTrash() { + return request>("/api/assets/trash/"); + }, + restoreAsset(id: string) { + return request(`/api/assets/${id}/restore/`, { method: "POST" }); + }, + purgeAsset(id: string) { + return request(`/api/assets/${id}/purge/`, { method: "DELETE" }); + }, preferences() { return request("/api/auth/me/preferences/"); }, @@ -176,6 +186,10 @@ export const api = { teamMembers() { return request("/api/auth/team/members/"); }, + // 团队级设置读取(任意成员可读):团队页/消费页取最新月限额,避免依赖登录时的陈旧 /me/ 快照(R72) + teamSettings() { + return request("/api/auth/team/settings/"); + }, // 团队级月限额持久化(超管在团队页设置 · PMC#12);monthly_credit_limit: 数值 / null(清空) updateTeamSettings(payload: { monthly_credit_limit?: number | string | null }) { return request("/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("/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(`/api/billing/trend/${range ? `?range=${range}` : ""}`); @@ -600,6 +617,13 @@ export const api = { // 以便 全部/已完成/失败 三个 tab 数按真实总量算。 return request>("/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 }>("/api/ai/tasks/unread/"); diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 6e5a8a1..c1c9af1 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -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 }) { diff --git a/core/frontend/src/components/product-create-drawer.tsx b/core/frontend/src/components/product-create-drawer.tsx index 487b979..977bc86 100644 --- a/core/frontend/src/components/product-create-drawer.tsx +++ b/core/frontend/src/components/product-create-drawer.tsx @@ -283,9 +283,15 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
- - {/* YYX#17:每条卖点 = 一行可直接编辑的输入;新增卖点走下方独立「添加卖点」按钮, - 不再在一个框里靠回车隐晦追加。 */} + {/* YYX#17/R104:显式「添加卖点」按钮放在字段标题行右侧(独立小按钮,对齐「+ 新增角色」摆法), + 不嵌在卖点输入框内/整行虚线框里;每条卖点 = 一行可直接编辑的输入,行内回车也追加新行。 */} +
+ + +
    {bullets.map((bullet, 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 小时持久水润" />
  • ))}
-
diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index a21abad..2ee7e60 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -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; } diff --git a/core/frontend/src/messages-page.css b/core/frontend/src/messages-page.css index dff0beb..207e312 100644 --- a/core/frontend/src/messages-page.css +++ b/core/frontend/src/messages-page.css @@ -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); } diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index 9eb60db..480d3a5 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -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; } } diff --git a/core/frontend/src/product-detail-page.css b/core/frontend/src/product-detail-page.css index cf97bb6..3915609 100644 --- a/core/frontend/src/product-detail-page.css +++ b/core/frontend/src/product-detail-page.css @@ -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 { diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index 1c83dfc..c19cc56 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -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; } diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index cab0d53..3dda8ed 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -129,29 +129,40 @@ export function AccountPage({ billing, projects, team, onRecharge }: { // 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载 const [trend, setTrend] = useState(null); const [teamMembers, setTeamMembers] = useState([]); + // 团队级月限额本页自取最新值:team prop 来自登录时 /me/ 快照,团队页改完月限额后不刷新会陈旧, + // 造成本页「本月限额」与团队页「月限额」对不上(R72) + const [freshTeam, setFreshTeam] = useState(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("all"); + const [billMember, setBillMember] = useState("all"); const [ledgerRows, setLedgerRows] = useState([]); const [ledgerCount, setLedgerCount] = useState(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("all"); - const [billMember, setBillMember] = useState("all"); - const billMemberOptions = useMemo(() => { - const set = new Set(); - 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 }: { {billFiltered && ( )} - 本页 {visibleLedgers.length} 条 · 共 {ledgerCount} 条 + 本页 {ledgerRows.length} 条 · 共 {ledgerCount} 条
@@ -432,9 +432,9 @@ export function AccountPage({ billing, projects, team, onRecharge }: { {ledgersLoading && ledgerRows.length === 0 ? ( - ) : visibleLedgers.length === 0 ? ( - - ) : visibleLedgers.map((l) => ( + ) : ledgerRows.length === 0 ? ( + + ) : ledgerRows.map((l) => ( diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index b2afc22..63c9f24 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -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, 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
{openBatch.label}// {openBatch.count} 张 · {(openBatch.created_at || "").slice(0, 10)}
- {/* row38:一键全部加入资产库 / 全部移出(整批切换) */} - {batchWithId.length > 0 && ( - - )} + {/* R109:成图自动入资产库,批次弹窗不再放「一键加入/移出资产库」按钮 */}
@@ -502,30 +467,13 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
{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 ( -
+
{u ? ( {a.name} setPreview({ src: u, name: a.name })} /> ) : (
#{i + 1}
)} - {/* row38:左上「已入库」绿标 + 右下角加入/取消按钮(单图粒度) */} - {u && inLib && ( - 已入库 - )} - {u && ( - - )}
); })} @@ -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 = 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(() => { 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(); + 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 ( -
+
{url ? ( {`${meta.title} setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> ) : ( @@ -1389,14 +1299,7 @@ export function ImageWorkbenchPage({ )}
)} - {/* ③ 左上「已入库」绿角标(该图已加入资产库时常驻) */} - {url && inLib && ( - - - 已入库 - - )} - {/* YYX#20:去掉「加入资产库」后的中央遮罩反馈,只保留左上角「已入库」标签 */} + {/* R109:成图自动入资产库 —— 去掉「已入库」角标与加入/取消入口 */} {/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */} {url && (
@@ -1410,13 +1313,7 @@ export function ImageWorkbenchPage({ - {/* YYX#20 更正:单图「更多」气泡保留「加入资产库」(单图粒度入库),这里不是重复入口; - 重复入口是批次底部那个,已在批次气泡里删掉。 */}
- {/* row41:仅图片创作(mode=image)显示「加入模特库」;模特上身图/平台套图不显示 */} {mode === "image" && ( )} - @@ -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} />
{renderBatchGrid(batch)} - {/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */} + {/* 行30/32:批次底部操作行(R109:成图自动入资产库,去掉「加入资产库」按钮) */}
- - {/* YYX#20:批次底部「加入资产库」已是上面的独立按钮,这里气泡只留「删除当前批次」,去掉重复入口 */}
- @@ -1651,16 +1544,14 @@ export function ImageWorkbenchPage({ 重跑 - + {/* R109:成图自动入资产库,去掉「加入资产库」按钮 */}
- @@ -1743,6 +1634,17 @@ export function ImageWorkbenchPage({
+ {/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */} + } + detail="删除的图片将回收到垃圾桶,是否需要删除?" + confirmText="删除" + onCancel={() => setConfirmDel(null)} + onConfirm={doConfirmedDelete} + />
); } @@ -2093,29 +1995,22 @@ export function ImageWorkbenchPage({ )}
// 提示词 · 描述你想要的画面
- {/* YYX#7:提示词框加大;YYX#10:生图模型选择作胶囊嵌在框内靠左置底 */} + {/* YYX#7/R83:提示词框加大 —— 外层容器即视觉上的「输入框」,内部无边框 textarea(默认 4-5 行起步,随内容自动增高,可手动拉伸)+ 底部工具条,胶囊不再绝对定位盖在文字上; + YYX#10/R86:生图模型选择作胶囊嵌在框内工具条靠左置底(复用图片创作 chat 输入栏同款 Pill,菜单向上弹) */}
// 加载中…
{billFiltered ? "// 本页无匹配筛选条件的账单" : "// 暂无账单流水"}
{billFiltered ? "// 无匹配筛选条件的账单" : "// 暂无账单流水"}
{new Date(l.created_at).toLocaleString("zh-CN")} {ledgerTypeLabel(l.ledger_type)}