fix: 完善商品—模特库-视频项目-自由创作等删除先进垃圾桶及可恢复等流程
This commit is contained in:
@@ -682,7 +682,7 @@ def finalize_free_video(*, task: AITask) -> AITask:
|
||||
return locked
|
||||
|
||||
|
||||
def serialize_free_video_task(task: AITask) -> dict:
|
||||
def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = False) -> dict:
|
||||
"""任务 → 前端契约。视频/封面直链从 generated_assets 取(TOS 公读),转存失败回落火山临时 URL。"""
|
||||
payload = task.request_payload or {}
|
||||
video_url = ""
|
||||
@@ -704,7 +704,7 @@ def serialize_free_video_task(task: AITask) -> dict:
|
||||
|
||||
generated_assets = list(task.generated_assets.all())
|
||||
for asset in generated_assets:
|
||||
if asset.is_deleted:
|
||||
if asset.purged_at is not None or (asset.is_deleted and not include_deleted_assets):
|
||||
continue
|
||||
for f in asset.files.all():
|
||||
url = _file_url(f)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0025_points_rescale"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="imageconversation",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aitask",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="imageconversation",
|
||||
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="ai_imagecon_team_id_651707_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="aitask",
|
||||
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="ai_aitask_team_id_1a915a_idx"),
|
||||
),
|
||||
]
|
||||
@@ -73,12 +73,14 @@ class ImageConversation(TeamOwnedModel):
|
||||
related_name="image_conversations",
|
||||
)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
# 每次在该对话里发起生成都刷新,左栏「最近」按它倒序
|
||||
last_active_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["team", "mode", "-last_active_at"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -149,6 +151,7 @@ class AITask(TeamOwnedModel):
|
||||
# 自由创作任务流的收藏 / 软删(其它任务类型恒 False,无行为影响)
|
||||
is_favorited = models.BooleanField(default=False)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
@@ -163,6 +166,7 @@ class AITask(TeamOwnedModel):
|
||||
models.Index(fields=["team", "read_at"]),
|
||||
# 自由创作任务流:按团队 + 类型倒序分页
|
||||
models.Index(fields=["team", "task_type", "-created_at"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -26,8 +26,11 @@ class ImageConversationSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ImageConversation
|
||||
fields = ["id", "title", "mode", "product", "task_count", "last_active_at", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "task_count", "last_active_at", "created_at", "updated_at"]
|
||||
fields = [
|
||||
"id", "title", "mode", "product", "task_count",
|
||||
"is_deleted", "purged_at", "last_active_at", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "task_count", "is_deleted", "purged_at", "last_active_at", "created_at", "updated_at"]
|
||||
|
||||
def get_task_count(self, obj) -> int:
|
||||
# list 接口已 annotate;无 annotate 时回落实时 count(详情/创建场景)
|
||||
@@ -65,6 +68,8 @@ class AITaskSerializer(serializers.ModelSerializer):
|
||||
"error_message",
|
||||
"submitted_at",
|
||||
"completed_at",
|
||||
"is_deleted",
|
||||
"purged_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -230,6 +230,14 @@ class FreeVideoAssetDeletionTests(TestCase):
|
||||
preview_url="http://tos.example/video.mp4",
|
||||
is_primary=True,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=self.asset,
|
||||
object_key="free-video-poster.jpg",
|
||||
bucket="b",
|
||||
content_type="image/jpeg",
|
||||
preview_url="http://tos.example/free-video-poster.jpg",
|
||||
is_primary=False,
|
||||
)
|
||||
|
||||
def test_deleted_persisted_asset_does_not_fall_back_to_raw_video_url(self):
|
||||
from apps.ai.free_video import serialize_free_video_task
|
||||
@@ -240,7 +248,76 @@ class FreeVideoAssetDeletionTests(TestCase):
|
||||
task = AITask.objects.prefetch_related("generated_assets", "generated_assets__files").get(id=self.task.id)
|
||||
data = serialize_free_video_task(task)
|
||||
self.assertEqual(data["video_url"], "")
|
||||
self.assertEqual(data["thumbnail_url"], "")
|
||||
self.assertEqual(data["fallback_note"], "fallback")
|
||||
trash_data = serialize_free_video_task(task, include_deleted_assets=True)
|
||||
self.assertEqual(trash_data["video_url"], "http://tos.example/video.mp4")
|
||||
self.assertEqual(trash_data["thumbnail_url"], "http://tos.example/free-video-poster.jpg")
|
||||
|
||||
def test_direct_asset_delete_stays_in_asset_trash(self):
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
|
||||
delete = client.delete(f"/api/assets/{self.asset.id}/")
|
||||
self.assertEqual(delete.status_code, 204)
|
||||
self.task.refresh_from_db()
|
||||
self.asset.refresh_from_db()
|
||||
self.assertFalse(self.task.is_deleted)
|
||||
self.assertTrue(self.asset.is_deleted)
|
||||
|
||||
trash_ids = {a["id"] for a in client.get("/api/assets/trash/").json()["results"]}
|
||||
self.assertIn(str(self.asset.id), trash_ids)
|
||||
self.assertEqual(client.get("/api/ai/free-video/").json()["total"], 0)
|
||||
|
||||
restore = client.post(f"/api/assets/{self.asset.id}/restore/")
|
||||
self.assertEqual(restore.status_code, 200)
|
||||
live_ids = {t["id"] for t in client.get("/api/ai/free-video/").json()["results"]}
|
||||
self.assertIn(str(self.task.id), live_ids)
|
||||
|
||||
def test_trash_restore_and_purge_free_video_keeps_record(self):
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
delete = self.client.delete(f"/api/ai/free-video/{self.task.id}/")
|
||||
self.assertEqual(delete.status_code, 204)
|
||||
self.task.refresh_from_db()
|
||||
self.asset.refresh_from_db()
|
||||
self.assertTrue(self.task.is_deleted)
|
||||
self.assertTrue(self.asset.is_deleted)
|
||||
self.assertIsNone(self.task.purged_at)
|
||||
self.assertEqual(self.client.get("/api/ai/free-video/").json()["total"], 0)
|
||||
trash_items = self.client.get("/api/ai/free-video/trash/").json()["results"]
|
||||
trash_ids = {t["id"] for t in trash_items}
|
||||
self.assertIn(str(self.task.id), trash_ids)
|
||||
asset_trash_ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
|
||||
self.assertNotIn(str(self.asset.id), asset_trash_ids)
|
||||
trash_item = next(t for t in trash_items if t["id"] == str(self.task.id))
|
||||
self.assertEqual(trash_item["thumbnail_url"], "http://tos.example/free-video-poster.jpg")
|
||||
|
||||
restore = self.client.post(f"/api/ai/free-video/{self.task.id}/restore/")
|
||||
self.assertEqual(restore.status_code, 200)
|
||||
self.task.refresh_from_db()
|
||||
self.asset.refresh_from_db()
|
||||
self.assertFalse(self.task.is_deleted)
|
||||
self.assertFalse(self.asset.is_deleted)
|
||||
self.assertIsNone(self.task.purged_at)
|
||||
live_ids = {t["id"] for t in self.client.get("/api/ai/free-video/").json()["results"]}
|
||||
self.assertIn(str(self.task.id), live_ids)
|
||||
|
||||
self.client.delete(f"/api/ai/free-video/{self.task.id}/")
|
||||
purge = self.client.delete(f"/api/ai/free-video/{self.task.id}/purge/")
|
||||
self.assertEqual(purge.status_code, 204)
|
||||
self.task.refresh_from_db()
|
||||
self.asset.refresh_from_db()
|
||||
self.assertTrue(self.task.is_deleted)
|
||||
self.assertTrue(self.asset.is_deleted)
|
||||
self.assertIsNotNone(self.task.purged_at)
|
||||
self.assertIsNotNone(self.asset.purged_at)
|
||||
self.assertTrue(AITask.objects.filter(id=self.task.id).exists())
|
||||
self.assertTrue(Asset.objects.filter(id=self.asset.id).exists())
|
||||
self.assertEqual(self.asset.files.count(), 2)
|
||||
trash_ids = {t["id"] for t in self.client.get("/api/ai/free-video/trash/").json()["results"]}
|
||||
self.assertNotIn(str(self.task.id), trash_ids)
|
||||
|
||||
|
||||
class StandaloneImageReferenceTests(TestCase):
|
||||
@@ -378,6 +455,138 @@ class ImageConversationTests(TestCase):
|
||||
self.assertEqual(self.client.get("/api/ai/image-conversations/?mode=image").json()["results"], [])
|
||||
self.assertTrue(ImageConversation.objects.get(id=conv_id).is_deleted)
|
||||
|
||||
def test_trash_restore_and_purge_conversation_keeps_record(self):
|
||||
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="待删", mode=ImageConversation.Mode.IMAGE)
|
||||
provider = ModelProvider.objects.create(name="conv-trash-provider", display_name="Conv Trash Provider")
|
||||
model = ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name="conv-trash-model",
|
||||
display_name="Conv Trash Model",
|
||||
capability=ModelConfig.Capability.IMAGE,
|
||||
)
|
||||
task = AITask.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
conversation=conv,
|
||||
task_type=AITask.Type.PRODUCT_IMAGE,
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
model_config=model,
|
||||
idempotency_key="conv-trash-task",
|
||||
)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="会话生成图",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.FREE_CREATE,
|
||||
origin_task=task,
|
||||
)
|
||||
asset_file = AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="free-create/image.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
delete = self.client.delete(f"/api/ai/image-conversations/{conv.id}/")
|
||||
self.assertIn(delete.status_code, (204, 200))
|
||||
conv.refresh_from_db()
|
||||
task.refresh_from_db()
|
||||
asset.refresh_from_db()
|
||||
self.assertTrue(conv.is_deleted)
|
||||
self.assertTrue(task.is_deleted)
|
||||
self.assertTrue(asset.is_deleted)
|
||||
self.assertIsNone(conv.purged_at)
|
||||
trash_ids = {c["id"] for c in self.client.get("/api/ai/image-conversations/trash/?mode=image").json()["results"]}
|
||||
self.assertIn(str(conv.id), trash_ids)
|
||||
asset_trash_ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
|
||||
self.assertNotIn(str(asset.id), asset_trash_ids)
|
||||
|
||||
restore = self.client.post(f"/api/ai/image-conversations/{conv.id}/restore/")
|
||||
self.assertEqual(restore.status_code, 200)
|
||||
conv.refresh_from_db()
|
||||
task.refresh_from_db()
|
||||
asset.refresh_from_db()
|
||||
self.assertFalse(conv.is_deleted)
|
||||
self.assertFalse(task.is_deleted)
|
||||
self.assertFalse(asset.is_deleted)
|
||||
self.assertIsNone(conv.purged_at)
|
||||
live_ids = {c["id"] for c in self.client.get("/api/ai/image-conversations/?mode=image").json()["results"]}
|
||||
self.assertIn(str(conv.id), live_ids)
|
||||
|
||||
self.client.delete(f"/api/ai/image-conversations/{conv.id}/")
|
||||
purge = self.client.delete(f"/api/ai/image-conversations/{conv.id}/purge/")
|
||||
self.assertEqual(purge.status_code, 204)
|
||||
conv.refresh_from_db()
|
||||
task.refresh_from_db()
|
||||
asset.refresh_from_db()
|
||||
self.assertTrue(conv.is_deleted)
|
||||
self.assertTrue(task.is_deleted)
|
||||
self.assertTrue(asset.is_deleted)
|
||||
self.assertIsNotNone(conv.purged_at)
|
||||
self.assertIsNotNone(task.purged_at)
|
||||
self.assertIsNotNone(asset.purged_at)
|
||||
self.assertTrue(ImageConversation.objects.filter(id=conv.id).exists())
|
||||
self.assertTrue(AITask.objects.filter(id=task.id).exists())
|
||||
self.assertTrue(Asset.objects.filter(id=asset.id).exists())
|
||||
self.assertTrue(AssetFile.objects.filter(id=asset_file.id).exists())
|
||||
trash_ids = {c["id"] for c in self.client.get("/api/ai/image-conversations/trash/?mode=image").json()["results"]}
|
||||
self.assertNotIn(str(conv.id), trash_ids)
|
||||
|
||||
def test_direct_asset_delete_hides_image_but_keeps_conversation(self):
|
||||
conv = ImageConversation.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
title="资产独立删除",
|
||||
mode=ImageConversation.Mode.IMAGE,
|
||||
)
|
||||
provider = ModelProvider.objects.create(name="asset-delete-provider", display_name="Asset Delete Provider")
|
||||
model = ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name="asset-delete-model",
|
||||
display_name="Asset Delete Model",
|
||||
capability=ModelConfig.Capability.IMAGE,
|
||||
)
|
||||
task = AITask.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
conversation=conv,
|
||||
task_type=AITask.Type.PRODUCT_IMAGE,
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
model_config=model,
|
||||
idempotency_key="direct-asset-delete-image",
|
||||
)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="自由创作图片",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.FREE_CREATE,
|
||||
origin_task=task,
|
||||
)
|
||||
|
||||
delete = self.client.delete(f"/api/assets/{asset.id}/")
|
||||
self.assertEqual(delete.status_code, 204)
|
||||
conv.refresh_from_db()
|
||||
task.refresh_from_db()
|
||||
self.assertFalse(conv.is_deleted)
|
||||
self.assertFalse(task.is_deleted)
|
||||
|
||||
conversation_ids = {
|
||||
c["id"] for c in self.client.get("/api/ai/image-conversations/?mode=image").json()["results"]
|
||||
}
|
||||
self.assertIn(str(conv.id), conversation_ids)
|
||||
deleted_tasks = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/").json()["tasks"]
|
||||
self.assertEqual(deleted_tasks[0]["assets"], [])
|
||||
|
||||
restore = self.client.post(f"/api/assets/{asset.id}/restore/")
|
||||
self.assertEqual(restore.status_code, 200)
|
||||
restored_tasks = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/").json()["tasks"]
|
||||
self.assertEqual([a["id"] for a in restored_tasks[0]["assets"]], [str(asset.id)])
|
||||
|
||||
def test_team_isolation(self):
|
||||
other = User.objects.create_user(username="other", password="pass")
|
||||
other_team = Team.objects.create(name="Other", owner=other)
|
||||
|
||||
@@ -5,7 +5,10 @@ from .views import (
|
||||
AITaskViewSet,
|
||||
FreeVideoDetailView,
|
||||
FreeVideoFavoriteView,
|
||||
FreeVideoPurgeView,
|
||||
FreeVideoPollView,
|
||||
FreeVideoRestoreView,
|
||||
FreeVideoTrashView,
|
||||
FreeVideoUploadView,
|
||||
FreeVideoView,
|
||||
GenerateImageView,
|
||||
@@ -21,8 +24,11 @@ router.register("image-conversations", ImageConversationViewSet, basename="image
|
||||
urlpatterns = [
|
||||
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
||||
path("free-video/", FreeVideoView.as_view(), name="ai-free-video"),
|
||||
path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"),
|
||||
path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"),
|
||||
path("free-video/<uuid:task_id>/poll/", FreeVideoPollView.as_view(), name="ai-free-video-poll"),
|
||||
path("free-video/<uuid:task_id>/favorite/", FreeVideoFavoriteView.as_view(), name="ai-free-video-favorite"),
|
||||
path("free-video/<uuid:task_id>/restore/", FreeVideoRestoreView.as_view(), name="ai-free-video-restore"),
|
||||
path("free-video/<uuid:task_id>/purge/", FreeVideoPurgeView.as_view(), name="ai-free-video-purge"),
|
||||
path("free-video/<uuid:task_id>/", FreeVideoDetailView.as_view(), name="ai-free-video-detail"),
|
||||
] + router.urls
|
||||
|
||||
+163
-18
@@ -1,4 +1,5 @@
|
||||
from django.db.models import Count
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, OuterRef
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -7,6 +8,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.serializers import AssetSerializer
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.celery_health import require_worker
|
||||
@@ -53,7 +55,9 @@ class GenerateImageView(APIView):
|
||||
# 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。
|
||||
conversation = None
|
||||
if conversation_id:
|
||||
conversation = ImageConversation.objects.filter(team=team, id=conversation_id, is_deleted=False).first()
|
||||
conversation = ImageConversation.objects.filter(
|
||||
team=team, id=conversation_id, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if conversation is None:
|
||||
conversation = ImageConversation.objects.create(
|
||||
team=team,
|
||||
@@ -92,7 +96,7 @@ class GenerateImageView(APIView):
|
||||
"status": t.status,
|
||||
"error_message": t.error_message,
|
||||
"assets": AssetSerializer(
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
|
||||
).data,
|
||||
}
|
||||
for t in tasks
|
||||
@@ -114,7 +118,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
def get_queryset(self):
|
||||
# 可选 ?task_type=a,b,c 过滤:生图工作室的任务中心只想看生图任务(模特上身图/平台套图/
|
||||
# 图片创作 = person_image / product_image),不掺脚本/实体抽取/故事板等流水线内部任务。
|
||||
queryset = super().get_queryset()
|
||||
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
|
||||
# 从 request_payload(已 defer)里只抽 batch_id / mode 两个 JSON 标量供前端「按批分组 + 标签」用:
|
||||
# KeyTextTransform 在 SQL 层 JSON_EXTRACT,不会把几 MB 的 payload 整列拉回(避开 payload 性能坑)。
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
@@ -211,7 +215,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
"created_at": t.created_at,
|
||||
# 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动)
|
||||
"assets": AssetSerializer(
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
|
||||
).data,
|
||||
}
|
||||
for t in tasks
|
||||
@@ -247,20 +251,63 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""
|
||||
|
||||
serializer_class = ImageConversationSerializer
|
||||
queryset = ImageConversation.objects.filter(is_deleted=False).select_related("product").order_by("-last_active_at")
|
||||
queryset = ImageConversation.objects.select_related("product").order_by("-last_active_at")
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
queryset = queryset.filter(is_deleted=True, purged_at__isnull=True)
|
||||
else:
|
||||
queryset = queryset.filter(is_deleted=False, purged_at__isnull=True)
|
||||
mode = self.request.query_params.get("mode", "").strip()
|
||||
if mode:
|
||||
queryset = queryset.filter(mode=mode)
|
||||
return queryset
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# 软删:对话从列表消失,但其 AITask.conversation 置空由 DB on_delete=SET_NULL 不触发(我们没真删),
|
||||
# 成图始终留在资产库。仅打标记。
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
# 只联动该会话生成任务产出的 Asset,不碰用户上传的参考素材。
|
||||
with transaction.atomic():
|
||||
Asset.objects.filter(team=instance.team, origin_task__conversation=instance, purged_at__isnull=True).update(is_deleted=True)
|
||||
AITask.objects.filter(team=instance.team, conversation=instance, purged_at__isnull=True).update(is_deleted=True)
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="trash")
|
||||
def trash(self, request):
|
||||
"""垃圾桶:列出本团队已软删且未彻底隐藏的自由创作图片会话。"""
|
||||
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):
|
||||
"""从垃圾桶恢复自由创作图片会话。"""
|
||||
conversation = self.get_object()
|
||||
with transaction.atomic():
|
||||
conversation.is_deleted = False
|
||||
conversation.purged_at = None
|
||||
conversation.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
|
||||
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
|
||||
return Response(self.get_serializer(conversation).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录保留。"""
|
||||
conversation = self.get_object()
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
conversation.purged_at = now
|
||||
conversation.save(update_fields=["purged_at", "updated_at"])
|
||||
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(
|
||||
is_deleted=True, purged_at=now
|
||||
)
|
||||
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(
|
||||
is_deleted=True, purged_at=now
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def tasks(self, request, pk=None):
|
||||
@@ -281,7 +328,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
for rid in ids or []:
|
||||
rid = str(rid)
|
||||
if rid not in ref_cache:
|
||||
a = Asset.objects.filter(id=rid).prefetch_related("files").first()
|
||||
a = Asset.objects.filter(id=rid, is_deleted=False, purged_at__isnull=True).prefetch_related("files").first()
|
||||
# 带上 id:前端重跑时凭它原样复用参考图(否则刷新恢复的批次只有 {name,url},重跑丢参考)
|
||||
ref_cache[rid] = {"id": rid, "name": a.name, "url": _asset_preview(a)} if a else None
|
||||
if ref_cache[rid]:
|
||||
@@ -301,7 +348,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")),
|
||||
"created_at": t.created_at,
|
||||
"assets": AssetSerializer(
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
|
||||
).data,
|
||||
}
|
||||
for t in tasks
|
||||
@@ -311,12 +358,42 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
def _free_video_task_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False)
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False, purged_at__isnull=True)
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
|
||||
|
||||
def _free_video_list_queryset(team):
|
||||
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
|
||||
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
|
||||
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
|
||||
return (
|
||||
_free_video_task_queryset(team)
|
||||
.annotate(
|
||||
_has_video_asset=Exists(video_assets),
|
||||
_has_active_video_asset=Exists(active_video_assets),
|
||||
)
|
||||
.exclude(
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
_has_video_asset=True,
|
||||
_has_active_video_asset=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _free_video_trash_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
|
||||
|
||||
def _set_free_video_generated_assets_deleted(task, deleted):
|
||||
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=deleted)
|
||||
|
||||
|
||||
class FreeVideoView(APIView):
|
||||
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
|
||||
|
||||
@@ -349,7 +426,7 @@ class FreeVideoView(APIView):
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_task_queryset(team).order_by("-created_at")
|
||||
qs = _free_video_list_queryset(team).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
@@ -389,7 +466,9 @@ class FreeVideoFavoriteView(APIView):
|
||||
|
||||
def post(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
task = AITask.objects.filter(
|
||||
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
task.is_favorited = not task.is_favorited
|
||||
@@ -402,13 +481,79 @@ class FreeVideoDetailView(APIView):
|
||||
|
||||
def delete(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
task = AITask.objects.filter(
|
||||
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING):
|
||||
return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
task.is_deleted = True
|
||||
task.save(update_fields=["is_deleted", "updated_at"])
|
||||
with transaction.atomic():
|
||||
_set_free_video_generated_assets_deleted(task, True)
|
||||
task.is_deleted = True
|
||||
task.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeVideoTrashView(APIView):
|
||||
"""GET /api/ai/free-video/trash/ —— 自由创作视频垃圾桶。"""
|
||||
|
||||
def get(self, request):
|
||||
from .free_video import serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
offset = max(0, int(request.query_params.get("offset") or 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
try:
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_trash_queryset(team).order_by("-updated_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_free_video_task(t, include_deleted_assets=True) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoRestoreView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/restore/ —— 从垃圾桶恢复自由创作视频。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
from .free_video import serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_trash_queryset(team).filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
with transaction.atomic():
|
||||
task.is_deleted = False
|
||||
task.purged_at = None
|
||||
task.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
_set_free_video_generated_assets_deleted(task, False)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class FreeVideoPurgeView(APIView):
|
||||
"""DELETE /api/ai/free-video/<id>/purge/ —— 二级软删除,从垃圾桶隐藏。"""
|
||||
|
||||
def delete(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_trash_queryset(team).filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
task.purged_at = now
|
||||
task.save(update_fields=["purged_at", "updated_at"])
|
||||
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=True, purged_at=now)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("assets", "0009_freeassetgroup_freeasset_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="asset",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="model",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="asset",
|
||||
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="assets_asse_team_id_d7db74_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="model",
|
||||
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="assets_mode_team_id_21e90d_idx"),
|
||||
),
|
||||
]
|
||||
@@ -55,6 +55,7 @@ class Asset(TeamOwnedModel):
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
# 是否进「资产库」列表:工作台生成的图(模特上身图/平台套图/自由创作)默认 False(只在工作台展示),
|
||||
# 用户「加入资产库」后才置 True 出现在资产库列表;「取消加入」置回 False 即从列表移出。
|
||||
# 字段默认 True 是为了让历史数据 / 上传 / 项目内资产保持原样可见(只对新生成的工作台图显式置 False)。
|
||||
@@ -69,6 +70,7 @@ class Asset(TeamOwnedModel):
|
||||
# 资产库按团队 + 分类倒序时间翻页(products 详情素材趴 / 资产库)
|
||||
models.Index(fields=["team", "category", "-created_at"]),
|
||||
models.Index(fields=["team", "asset_type"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -102,11 +104,13 @@ class Model(TeamOwnedModel):
|
||||
description = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
# 模特库默认按团队 + 新→旧;官方模板优先靠前由序列化/查询控制
|
||||
models.Index(fields=["team", "is_official", "-created_at"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ class ModelLibrarySerializer(serializers.ModelSerializer):
|
||||
"source",
|
||||
"description",
|
||||
"metadata",
|
||||
"is_deleted",
|
||||
"purged_at",
|
||||
"portrait_asset",
|
||||
"triview_asset",
|
||||
"voice_asset",
|
||||
@@ -150,7 +152,7 @@ class ModelLibrarySerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "is_official", "source", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "is_official", "source", "is_deleted", "purged_at", "created_at", "updated_at"]
|
||||
|
||||
def get_portrait(self, obj):
|
||||
return _asset_preview(obj.portrait_asset)
|
||||
|
||||
@@ -9,7 +9,7 @@ from rest_framework.test import APIClient
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
|
||||
from . import assets_client
|
||||
from .models import Asset, FreeAsset, FreeAssetGroup, Model
|
||||
from .models import Asset, AssetFile, FreeAsset, FreeAssetGroup, Model
|
||||
|
||||
|
||||
def _mk_team(username, team_name):
|
||||
@@ -56,6 +56,48 @@ class ModelLibraryApiTests(TestCase):
|
||||
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
|
||||
self.assertNotIn(str(self.mine.id), ids)
|
||||
|
||||
def test_trash_restore_and_purge_model_keeps_record_and_asset(self):
|
||||
portrait = Asset.objects.create(team=self.teamA, name="模特形象", asset_type="image", source="upload")
|
||||
portrait_file = AssetFile.objects.create(
|
||||
asset=portrait,
|
||||
object_key="models/portrait.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
is_primary=True,
|
||||
)
|
||||
self.mine.portrait_asset = portrait
|
||||
self.mine.save(update_fields=["portrait_asset"])
|
||||
|
||||
self.client.delete(f"/api/models/{self.mine.id}/")
|
||||
trash_ids = {m["id"] for m in self.client.get("/api/models/trash/").json()["results"]}
|
||||
self.assertIn(str(self.mine.id), trash_ids)
|
||||
portrait.refresh_from_db()
|
||||
self.assertFalse(portrait.is_deleted)
|
||||
self.assertIsNone(portrait.purged_at)
|
||||
|
||||
restore = self.client.post(f"/api/models/{self.mine.id}/restore/")
|
||||
self.assertEqual(restore.status_code, 200)
|
||||
self.mine.refresh_from_db()
|
||||
self.assertFalse(self.mine.is_deleted)
|
||||
self.assertIsNone(self.mine.purged_at)
|
||||
ids = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
|
||||
self.assertIn(str(self.mine.id), ids)
|
||||
|
||||
self.client.delete(f"/api/models/{self.mine.id}/")
|
||||
purge = self.client.delete(f"/api/models/{self.mine.id}/purge/")
|
||||
self.assertEqual(purge.status_code, 204)
|
||||
self.mine.refresh_from_db()
|
||||
self.assertTrue(self.mine.is_deleted)
|
||||
self.assertIsNotNone(self.mine.purged_at)
|
||||
self.assertTrue(Model.objects.filter(id=self.mine.id).exists())
|
||||
self.assertTrue(Asset.objects.filter(id=portrait.id).exists())
|
||||
portrait.refresh_from_db()
|
||||
self.assertFalse(portrait.is_deleted)
|
||||
self.assertIsNone(portrait.purged_at)
|
||||
self.assertTrue(AssetFile.objects.filter(id=portrait_file.id).exists())
|
||||
trash_ids = {m["id"] for m in self.client.get("/api/models/trash/").json()["results"]}
|
||||
self.assertNotIn(str(self.mine.id), trash_ids)
|
||||
|
||||
def test_official_cannot_be_deleted(self):
|
||||
res = self.client.delete(f"/api/models/{self.official.id}/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
@@ -108,7 +150,7 @@ class AssetSoftDeleteTests(TestCase):
|
||||
|
||||
|
||||
class AssetTrashApiTests(TestCase):
|
||||
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 硬删且被引用时 409。"""
|
||||
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 二级软删。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user, self.team = _mk_team("utr", "TeamTR")
|
||||
@@ -117,6 +159,13 @@ class AssetTrashApiTests(TestCase):
|
||||
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)
|
||||
self.dead_file = AssetFile.objects.create(
|
||||
asset=self.dead,
|
||||
object_key="assets/deleted.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
is_primary=True,
|
||||
)
|
||||
# 别队软删资产:不得出现在本队垃圾桶(team 隔离)
|
||||
self.other_dead = Asset.objects.create(team=self.other_team, name="别队删图", asset_type="image", source="ai_generated", is_deleted=True)
|
||||
|
||||
@@ -134,19 +183,26 @@ class AssetTrashApiTests(TestCase):
|
||||
# 恢复后不再在垃圾桶
|
||||
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
|
||||
|
||||
def test_purge_hard_deletes(self):
|
||||
def test_purge_soft_hides_from_trash(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())
|
||||
self.dead.refresh_from_db()
|
||||
self.assertIsNotNone(self.dead.purged_at)
|
||||
self.assertTrue(Asset.objects.filter(id=self.dead.id).exists())
|
||||
self.assertTrue(AssetFile.objects.filter(id=self.dead_file.id).exists())
|
||||
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
|
||||
|
||||
def test_purge_referenced_asset_returns_409(self):
|
||||
def test_purge_referenced_asset_keeps_record(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()) # 留在垃圾桶可恢复
|
||||
self.assertEqual(res.status_code, 204)
|
||||
self.dead.refresh_from_db()
|
||||
self.assertTrue(self.dead.is_deleted)
|
||||
self.assertIsNotNone(self.dead.purged_at)
|
||||
self.assertTrue(Asset.objects.filter(id=self.dead.id).exists())
|
||||
|
||||
def test_restore_rejects_live_asset(self):
|
||||
# 未删资产不在 trash 态 queryset → 404(与商品垃圾桶口径一致)
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
@@ -120,8 +120,15 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 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) # 软删资产不出现在资产库
|
||||
trash_qs = qs.filter(is_deleted=True, purged_at__isnull=True)
|
||||
# 自由创作父记录已进垃圾桶时,生成 Asset 由父记录统一恢复/彻底隐藏,
|
||||
# 不在“资产”分类重复展示。单独从资产库删除时父记录仍为正常态,仍会出现在这里。
|
||||
owned_by_deleted_free_creation = (
|
||||
Q(origin_task__task_type="free_video", origin_task__is_deleted=True)
|
||||
| Q(origin_task__conversation__is_deleted=True)
|
||||
)
|
||||
return trash_qs.exclude(owned_by_deleted_free_creation).order_by("-updated_at")
|
||||
qs = qs.filter(is_deleted=False, purged_at__isnull=True) # 软删资产不出现在资产库
|
||||
p = self.request.query_params
|
||||
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
|
||||
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
|
||||
@@ -176,7 +183,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""资产库 tab 计数,供 tab 徽标——不必取全量。
|
||||
图片趴三类(模特上身图/平台套图/自由创作)按「生成批次」计数(与卡片成组展示一致);
|
||||
其他(我的上传 + 兜底)仍按资产张数计。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True, in_library=True)
|
||||
out = {}
|
||||
for t in BATCH_TABS: # 图片趴:数批次
|
||||
seen = set()
|
||||
@@ -279,23 +286,16 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
(均过滤 is_deleted),恢复后各处即联动复现;in_library 保持删除前原样,不额外置位。"""
|
||||
asset = self.get_object() # trash 态 queryset:只有软删资产可恢复
|
||||
asset.is_deleted = False
|
||||
asset.save(update_fields=["is_deleted", "updated_at"])
|
||||
asset.purged_at = None
|
||||
asset.save(update_fields=["is_deleted", "purged_at", "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。"""
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和 TOS 文件保留。"""
|
||||
asset = self.get_object()
|
||||
try:
|
||||
asset.delete()
|
||||
except ProtectedError:
|
||||
return Response(
|
||||
{"detail": "该资产仍被商品图/分镜/视频片段等引用,无法彻底删除;可恢复后先解除引用"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
asset.purged_at = timezone.now()
|
||||
asset.save(update_fields=["purged_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="video-packs")
|
||||
@@ -323,7 +323,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
clips = []
|
||||
for seg in segs:
|
||||
a = getattr(seg.adopted_version, "asset", None)
|
||||
if a is None or a.is_deleted:
|
||||
if a is None or a.is_deleted or a.purged_at is not None:
|
||||
continue
|
||||
clips.append({"id": str(a.id), "name": a.name, "url": _asset_preview(a)})
|
||||
if not clips:
|
||||
@@ -342,7 +342,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def facets(self, request):
|
||||
"""某 tab 下真实存在的筛选项(来源/类型/关联商品/指定 metadata 键的取值),供下拉「只列真有的」。
|
||||
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True, in_library=True)
|
||||
if request.query_params.get("tab"):
|
||||
base = base.filter(_tab_q(request.query_params["tab"]))
|
||||
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)
|
||||
@@ -430,8 +430,14 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
team = self.get_team()
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
return (
|
||||
Model.objects.filter(team=team, is_official=False, is_deleted=True, purged_at__isnull=True)
|
||||
.prefetch_related("portrait_asset__files", "triview_asset__files")
|
||||
.order_by("-updated_at")
|
||||
)
|
||||
qs = (
|
||||
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False)
|
||||
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False, purged_at__isnull=True)
|
||||
.prefetch_related("portrait_asset__files", "triview_asset__files")
|
||||
)
|
||||
tab = self.request.query_params.get("tab")
|
||||
@@ -456,6 +462,32 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="trash")
|
||||
def trash(self, request):
|
||||
"""垃圾桶:列出本团队已软删且未彻底隐藏的自建模特。"""
|
||||
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):
|
||||
"""从垃圾桶恢复模特。"""
|
||||
model = self.get_object()
|
||||
model.is_deleted = False
|
||||
model.purged_at = None
|
||||
model.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
return Response(self.get_serializer(model).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和关联资产保留。"""
|
||||
model = self.get_object()
|
||||
model.purged_at = timezone.now()
|
||||
model.save(update_fields=["purged_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
|
||||
def upload(self, request):
|
||||
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
|
||||
@@ -507,10 +539,10 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
资产生成时已落库扣过费,此处只引用、不重新上传/不二次扣费。同一资产已建过则幂等返回。"""
|
||||
team = self.get_team()
|
||||
asset_id = request.data.get("asset_id")
|
||||
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False).first() if asset_id else None
|
||||
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False, purged_at__isnull=True).first() if asset_id else None
|
||||
if asset is None:
|
||||
raise ValidationError({"asset_id": "资产不存在或不属于本团队"})
|
||||
existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False).first()
|
||||
existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False, purged_at__isnull=True).first()
|
||||
if existing is not None:
|
||||
return Response(ModelLibrarySerializer(existing).data, status=status.HTTP_200_OK)
|
||||
name = (request.data.get("name") or (asset.name or "模特").split("·")[0].strip() or "模特")[:255]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0002_product_products_pr_team_id_ebc2f5_idx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="product",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="product",
|
||||
index=models.Index(fields=["team", "status", "purged_at"], name="products_pr_team_id_47f4ec_idx"),
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,7 @@ class Product(TeamOwnedModel):
|
||||
specs = models.JSONField(default=dict, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
cover_asset = models.ForeignKey(
|
||||
"assets.Asset",
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -26,6 +27,7 @@ class Product(TeamOwnedModel):
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["team", "status"]),
|
||||
models.Index(fields=["team", "status", "purged_at"]),
|
||||
models.Index(fields=["team", "category"]),
|
||||
# 商品库默认按团队 + 创建时间倒序翻页
|
||||
models.Index(fields=["team", "-created_at"]),
|
||||
|
||||
@@ -2,12 +2,13 @@ from rest_framework.test import APIClient
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
|
||||
from .models import Product
|
||||
from .models import Product, ProductImage
|
||||
|
||||
|
||||
class ProductTrashTests(TestCase):
|
||||
"""软删除 = 进垃圾桶(status=archived):正常列表只看 active,可恢复 / 可彻底删。"""
|
||||
"""软删除 = 进垃圾桶(status=archived):正常列表只看 active,可恢复 / 可二级软删。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="u1", password="x")
|
||||
@@ -16,12 +17,34 @@ class ProductTrashTests(TestCase):
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="商品A")
|
||||
self.asset = 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,
|
||||
)
|
||||
self.asset_file = AssetFile.objects.create(
|
||||
asset=self.asset,
|
||||
object_key="products/trash-cover.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
is_primary=True,
|
||||
)
|
||||
ProductImage.objects.create(product=self.product, asset=self.asset, is_primary=True)
|
||||
self.product.cover_asset = self.asset
|
||||
self.product.save(update_fields=["cover_asset"])
|
||||
|
||||
def test_delete_is_soft_and_hidden_from_list(self):
|
||||
res = self.client.delete(f"/api/products/{self.product.id}/")
|
||||
self.assertEqual(res.status_code, 204)
|
||||
self.product.refresh_from_db()
|
||||
self.assertEqual(self.product.status, Product.Status.ARCHIVED) # 软删,数据还在
|
||||
self.asset.refresh_from_db()
|
||||
self.assertFalse(self.asset.is_deleted)
|
||||
self.assertIsNone(self.asset.purged_at)
|
||||
self.assertTrue(AssetFile.objects.filter(id=self.asset_file.id).exists())
|
||||
# 正常列表不含已删
|
||||
ids = [p["id"] for p in self.client.get("/api/products/").json()["results"]]
|
||||
self.assertNotIn(str(self.product.id), ids)
|
||||
@@ -37,14 +60,25 @@ class ProductTrashTests(TestCase):
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.product.refresh_from_db()
|
||||
self.assertEqual(self.product.status, Product.Status.ACTIVE)
|
||||
self.asset.refresh_from_db()
|
||||
self.assertFalse(self.asset.is_deleted)
|
||||
self.assertIsNone(self.asset.purged_at)
|
||||
ids = [p["id"] for p in self.client.get("/api/products/").json()["results"]]
|
||||
self.assertIn(str(self.product.id), ids)
|
||||
|
||||
def test_purge_hard_deletes(self):
|
||||
def test_purge_soft_hides_from_trash(self):
|
||||
self.client.delete(f"/api/products/{self.product.id}/")
|
||||
res = self.client.delete(f"/api/products/{self.product.id}/purge/")
|
||||
self.assertEqual(res.status_code, 204)
|
||||
self.assertFalse(Product.objects.filter(id=self.product.id).exists())
|
||||
self.product.refresh_from_db()
|
||||
self.assertIsNotNone(self.product.purged_at)
|
||||
self.assertTrue(Product.objects.filter(id=self.product.id).exists())
|
||||
self.asset.refresh_from_db()
|
||||
self.assertFalse(self.asset.is_deleted)
|
||||
self.assertIsNone(self.asset.purged_at)
|
||||
self.assertTrue(AssetFile.objects.filter(id=self.asset_file.id).exists())
|
||||
trash_ids = [p["id"] for p in self.client.get("/api/products/trash/").json()["results"]]
|
||||
self.assertNotIn(str(self.product.id), trash_ids)
|
||||
|
||||
def test_purge_rejects_active(self):
|
||||
# 未删除(active)商品不在垃圾桶 qs → 彻底删找不到
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
@@ -32,8 +33,8 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 回收站相关动作(trash/restore/purge)才看 archived。
|
||||
qs = super().get_queryset()
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
return qs.filter(status=Product.Status.ARCHIVED)
|
||||
return qs.filter(status=Product.Status.ACTIVE)
|
||||
return qs.filter(status=Product.Status.ARCHIVED, purged_at__isnull=True)
|
||||
return qs.filter(status=Product.Status.ACTIVE, purged_at__isnull=True)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# 删除 = 软删进垃圾桶(可恢复),不真删数据
|
||||
@@ -61,7 +62,7 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 含 request_payload / response_payload 两个巨型 JSON 列(实测 114 行就要 ~66s 纯传输)。
|
||||
# 视图与序列化器只用到 origin_task.project(取项目名/id),从不读 payload → .defer() 掉这两列。
|
||||
base = (
|
||||
Asset.objects.filter(team=team, is_deleted=False)
|
||||
Asset.objects.filter(team=team, is_deleted=False, purged_at__isnull=True)
|
||||
.select_related("origin_task__project")
|
||||
.defer("origin_task__request_payload", "origin_task__response_payload")
|
||||
.prefetch_related("files")
|
||||
@@ -146,14 +147,16 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""从垃圾桶恢复:archived → active。"""
|
||||
product = self.get_object()
|
||||
product.status = Product.Status.ACTIVE
|
||||
product.save(update_fields=["status", "updated_at"])
|
||||
product.purged_at = None
|
||||
product.save(update_fields=["status", "purged_at", "updated_at"])
|
||||
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除(仅垃圾桶内 archived 商品可彻底删,不可恢复)。"""
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录保留。"""
|
||||
product = self.get_object()
|
||||
product.delete()
|
||||
product.purged_at = timezone.now()
|
||||
product.save(update_fields=["purged_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="images", parser_classes=[MultiPartParser, FormParser])
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("projects", "0006_migrate_storyboard_to_shots"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="is_deleted",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="project",
|
||||
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="projects_pr_team_id_9bc32b_idx"),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,8 @@ class Project(TeamOwnedModel):
|
||||
budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
failure_reason = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
@@ -30,6 +32,7 @@ class Project(TeamOwnedModel):
|
||||
models.Index(fields=["team", "current_stage"]),
|
||||
# 项目列表默认按团队 + 更新时间倒序
|
||||
models.Index(fields=["team", "-updated_at"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -327,7 +327,7 @@ class ProjectListSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
"id", "name", "product", "product_title", "cover_preview_url",
|
||||
"status", "current_stage", "script_version_count", "video_segment_count",
|
||||
"created_at", "updated_at",
|
||||
"is_deleted", "purged_at", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
def get_cover_preview_url(self, obj) -> str:
|
||||
@@ -354,6 +354,8 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
"budget_limit",
|
||||
"failure_reason",
|
||||
"metadata",
|
||||
"is_deleted",
|
||||
"purged_at",
|
||||
"stages",
|
||||
"script_versions",
|
||||
"base_asset_groups",
|
||||
@@ -364,4 +366,4 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "status", "current_stage", "failure_reason", "is_deleted", "purged_at", "created_at", "updated_at"]
|
||||
|
||||
@@ -5,7 +5,7 @@ from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import (
|
||||
@@ -88,6 +88,66 @@ class ProjectApiTests(TestCase):
|
||||
)
|
||||
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
|
||||
|
||||
def test_delete_restore_and_purge_project_trash(self):
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="Trash Project")
|
||||
segment = VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="项目视频片段",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
asset_file = AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="projects/clip.mp4",
|
||||
bucket="test",
|
||||
content_type="video/mp4",
|
||||
is_primary=True,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
|
||||
segment.adopted_version = version
|
||||
segment.save(update_fields=["adopted_version"])
|
||||
|
||||
delete = self.client.delete(f"/api/projects/{project.id}/")
|
||||
self.assertEqual(delete.status_code, 204)
|
||||
project.refresh_from_db()
|
||||
self.assertTrue(project.is_deleted)
|
||||
self.assertIsNone(project.purged_at)
|
||||
list_ids = [p["id"] for p in self.client.get("/api/projects/").json()["results"]]
|
||||
self.assertNotIn(str(project.id), list_ids)
|
||||
trash_ids = [p["id"] for p in self.client.get("/api/projects/trash/").json()["results"]]
|
||||
self.assertIn(str(project.id), trash_ids)
|
||||
asset.refresh_from_db()
|
||||
self.assertFalse(asset.is_deleted)
|
||||
self.assertIsNone(asset.purged_at)
|
||||
|
||||
restore = self.client.post(f"/api/projects/{project.id}/restore/")
|
||||
self.assertEqual(restore.status_code, 200)
|
||||
project.refresh_from_db()
|
||||
self.assertFalse(project.is_deleted)
|
||||
self.assertIsNone(project.purged_at)
|
||||
list_ids = [p["id"] for p in self.client.get("/api/projects/").json()["results"]]
|
||||
self.assertIn(str(project.id), list_ids)
|
||||
self.assertTrue(VideoSegmentVersion.objects.filter(id=version.id).exists())
|
||||
|
||||
self.client.delete(f"/api/projects/{project.id}/")
|
||||
purge = self.client.delete(f"/api/projects/{project.id}/purge/")
|
||||
self.assertEqual(purge.status_code, 204)
|
||||
project.refresh_from_db()
|
||||
self.assertTrue(project.is_deleted)
|
||||
self.assertIsNotNone(project.purged_at)
|
||||
self.assertTrue(Project.objects.filter(id=project.id).exists())
|
||||
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
|
||||
self.assertTrue(VideoSegmentVersion.objects.filter(id=version.id).exists())
|
||||
asset.refresh_from_db()
|
||||
self.assertFalse(asset.is_deleted)
|
||||
self.assertIsNone(asset.purged_at)
|
||||
self.assertTrue(AssetFile.objects.filter(id=asset_file.id).exists())
|
||||
trash_ids = [p["id"] for p in self.client.get("/api/projects/trash/").json()["results"]]
|
||||
self.assertNotIn(str(project.id), trash_ids)
|
||||
|
||||
@patch("apps.ai.services.VolcanoArkProvider")
|
||||
def test_rerun_script_segment_via_agent_rewrites_one_and_charges_once(self, provider_cls):
|
||||
"""单镜重跑改走 agent:读结构化全脚本,只重写目标镜、保留其余镜,落新版本,计费一次。"""
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
@@ -246,7 +247,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return # 项目内已有商品三视图,不覆盖
|
||||
tri = (
|
||||
Asset.objects.filter(
|
||||
team=team, is_deleted=False,
|
||||
team=team, is_deleted=False, purged_at__isnull=True,
|
||||
metadata__product_id=str(product_id), metadata__view="three_view",
|
||||
)
|
||||
.order_by("-created_at")
|
||||
@@ -267,7 +268,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 按采用版分镜数收口一次(幂等;只裁从未生成过的尾段,生成中/已出片的段绝不动)。
|
||||
# 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。
|
||||
proj = (
|
||||
Project.objects.filter(pk=kwargs.get("pk"), **{self.team_field: self.get_team()})
|
||||
Project.objects.filter(
|
||||
pk=kwargs.get("pk"),
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
**{self.team_field: self.get_team()},
|
||||
)
|
||||
.only("id", "current_stage", "status", "product_id")
|
||||
.first()
|
||||
)
|
||||
@@ -289,6 +295,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
return (
|
||||
Project.objects.select_related("product", "product__cover_asset")
|
||||
.prefetch_related("product__cover_asset__files")
|
||||
.filter(is_deleted=True, purged_at__isnull=True, **{self.team_field: self.get_team()})
|
||||
.order_by("-updated_at")
|
||||
)
|
||||
# 列表:轻查询(只 select_related product + 计数注解),不做详情那串 12 个重 prefetch
|
||||
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
|
||||
if self.action == "list":
|
||||
@@ -300,8 +313,43 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
video_segment_count=Count("video_segments", distinct=True),
|
||||
)
|
||||
)
|
||||
return qs.filter(**{self.team_field: self.get_team()}).order_by("-updated_at")
|
||||
return super().get_queryset()
|
||||
return qs.filter(
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
**{self.team_field: self.get_team()},
|
||||
).order_by("-updated_at")
|
||||
return super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="trash")
|
||||
def trash(self, request):
|
||||
"""垃圾桶:列出本团队已软删且未彻底隐藏的视频项目。"""
|
||||
qs = self.filter_queryset(self.get_queryset())
|
||||
page = self.paginate_queryset(qs)
|
||||
serializer_cls = ProjectListSerializer
|
||||
if page is not None:
|
||||
return self.get_paginated_response(serializer_cls(page, many=True).data)
|
||||
return Response(serializer_cls(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="restore")
|
||||
def restore(self, request, pk=None):
|
||||
"""从垃圾桶恢复视频项目。"""
|
||||
project = self.get_object()
|
||||
project.is_deleted = False
|
||||
project.purged_at = None
|
||||
project.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
return Response(ProjectSerializer(project).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,项目 DB 记录保留。"""
|
||||
project = self.get_object()
|
||||
project.purged_at = timezone.now()
|
||||
project.save(update_fields=["purged_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="export-clips")
|
||||
def export_clips(self, request, pk=None):
|
||||
@@ -359,7 +407,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@action(detail=False, methods=["get"])
|
||||
def summary(self, request):
|
||||
"""项目统计(总数 + 各状态计数),供仪表盘/侧栏徽标——只跑 COUNT,不拉项目对象。"""
|
||||
base = Project.objects.filter(team=self.get_team())
|
||||
base = Project.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True)
|
||||
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
|
||||
return Response({"total": base.count(), "by_status": by_status})
|
||||
|
||||
@@ -594,7 +642,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if asset is None and asset_id:
|
||||
# 跨团队可见的官方模特形象图 / 三视图 → 克隆进本团队再挂
|
||||
official = (
|
||||
Asset.objects.filter(id=asset_id, is_deleted=False)
|
||||
Asset.objects.filter(id=asset_id, is_deleted=False, purged_at__isnull=True)
|
||||
.filter(Q(model_as_portrait__is_official=True) | Q(model_as_triview__is_official=True))
|
||||
.first()
|
||||
)
|
||||
@@ -659,7 +707,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if source_portrait_id:
|
||||
candidate_ids.add(str(source_portrait_id))
|
||||
model = (
|
||||
ModelEntity.objects.filter(portrait_asset_id__in=candidate_ids, is_deleted=False)
|
||||
ModelEntity.objects.filter(portrait_asset_id__in=candidate_ids, is_deleted=False, purged_at__isnull=True)
|
||||
.exclude(triview_asset__isnull=True)
|
||||
.select_related("triview_asset")
|
||||
.first()
|
||||
|
||||
@@ -833,7 +833,7 @@ export function App() {
|
||||
</div>
|
||||
);
|
||||
case "models":
|
||||
return <ModelsPage />;
|
||||
return <ModelsPage onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "library":
|
||||
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
|
||||
case "trash":
|
||||
@@ -842,6 +842,7 @@ export function App() {
|
||||
navigate={navigate}
|
||||
onRestore={(id) => action(() => api.restoreProduct(id), "已恢复到商品库")}
|
||||
onPurge={(id) => action(() => api.purgeProduct(id), "已彻底删除")}
|
||||
onChanged={() => { void loadData(); }}
|
||||
/>
|
||||
);
|
||||
case "account":
|
||||
|
||||
@@ -277,7 +277,7 @@ export const api = {
|
||||
return request<Product>(`/api/products/${id}/restore/`, { method: "POST" });
|
||||
},
|
||||
purgeProduct(id: string) {
|
||||
// 彻底删除(不可恢复)
|
||||
// 彻底删除 = 二级软删除(从垃圾桶隐藏,DB 记录保留)
|
||||
return request<void>(`/api/products/${id}/purge/`, { method: "DELETE" });
|
||||
},
|
||||
projects() {
|
||||
@@ -302,6 +302,15 @@ export const api = {
|
||||
deleteProject(id: string) {
|
||||
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
projectsTrash() {
|
||||
return request<Paginated<Project>>("/api/projects/trash/");
|
||||
},
|
||||
restoreProject(id: string) {
|
||||
return request<Project>(`/api/projects/${id}/restore/`, { method: "POST" });
|
||||
},
|
||||
purgeProject(id: string) {
|
||||
return request<void>(`/api/projects/${id}/purge/`, { method: "DELETE" });
|
||||
},
|
||||
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
|
||||
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
|
||||
async agentScriptStream(
|
||||
@@ -603,6 +612,15 @@ export const api = {
|
||||
deleteModel(id: string) {
|
||||
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
modelsTrash() {
|
||||
return request<Paginated<ModelEntity>>("/api/models/trash/");
|
||||
},
|
||||
restoreModel(id: string) {
|
||||
return request<ModelEntity>(`/api/models/${id}/restore/`, { method: "POST" });
|
||||
},
|
||||
purgeModel(id: string) {
|
||||
return request<void>(`/api/models/${id}/purge/`, { method: "DELETE" });
|
||||
},
|
||||
billingSummary() {
|
||||
return request<BillingSummary>("/api/billing/summary/");
|
||||
},
|
||||
@@ -650,6 +668,9 @@ export const api = {
|
||||
listConversations(mode: "image" | "model" | "cover" = "image") {
|
||||
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/?mode=${mode}&page_size=100`);
|
||||
},
|
||||
conversationsTrash(mode: "image" | "model" | "cover" = "image") {
|
||||
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`);
|
||||
},
|
||||
createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) {
|
||||
return request<ImageConversation>("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
@@ -659,6 +680,12 @@ export const api = {
|
||||
deleteConversation(id: string) {
|
||||
return request<void>(`/api/ai/image-conversations/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
restoreConversation(id: string) {
|
||||
return request<ImageConversation>(`/api/ai/image-conversations/${id}/restore/`, { method: "POST" });
|
||||
},
|
||||
purgeConversation(id: string) {
|
||||
return request<void>(`/api/ai/image-conversations/${id}/purge/`, { method: "DELETE" });
|
||||
},
|
||||
// 切换对话时回填:该对话历次生成的任务 + 成图
|
||||
conversationTasks(id: string) {
|
||||
return request<{ conversation_id: string; tasks: ImageConversationTask[] }>(`/api/ai/image-conversations/${id}/tasks/`);
|
||||
@@ -686,6 +713,11 @@ export const api = {
|
||||
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
|
||||
);
|
||||
},
|
||||
freeVideoTrash(offset = 0, pageSize = 20) {
|
||||
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
|
||||
`/api/ai/free-video/trash/?offset=${offset}&page_size=${pageSize}`
|
||||
);
|
||||
},
|
||||
// web 进程内单次轮询+终态化(幂等):前端渐进轮询打这里,本地无 worker 也能收尾
|
||||
pollFreeVideo(id: string) {
|
||||
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/poll/`, { method: "POST" });
|
||||
@@ -696,6 +728,12 @@ export const api = {
|
||||
deleteFreeVideo(id: string) {
|
||||
return request<void>(`/api/ai/free-video/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
restoreFreeVideo(id: string) {
|
||||
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/restore/`, { method: "POST" });
|
||||
},
|
||||
purgeFreeVideo(id: string) {
|
||||
return request<void>(`/api/ai/free-video/${id}/purge/`, { method: "DELETE" });
|
||||
},
|
||||
uploadFreeVideoRef(formData: FormData) {
|
||||
return request<FreeVideoUploadResult>("/api/ai/free-video/upload/", { method: "POST", body: formData });
|
||||
},
|
||||
|
||||
@@ -67,8 +67,22 @@
|
||||
gap: 8px;
|
||||
background: var(--black-alpha-56);
|
||||
}
|
||||
.model-confirm .btn {
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
border-color: var(--border-faint);
|
||||
}
|
||||
.model-confirm .btn:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--border-loud);
|
||||
}
|
||||
.model-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
|
||||
.model-del:hover { filter: brightness(1.06); }
|
||||
.model-confirm .model-del,
|
||||
.model-confirm .model-del:hover {
|
||||
background: var(--accent-crimson);
|
||||
border-color: var(--accent-crimson);
|
||||
color: var(--accent-white);
|
||||
}
|
||||
|
||||
.model-body { padding: 10px 12px 12px; }
|
||||
.model-name {
|
||||
|
||||
@@ -434,7 +434,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
||||
setTasks((prev) => prev.filter((t) => t.id !== target.id));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
if (detailId === target.id) setDetailId(null);
|
||||
notify("success", "已删除");
|
||||
notify("success", "已移至垃圾桶");
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "删除失败");
|
||||
} finally {
|
||||
|
||||
@@ -74,7 +74,7 @@ function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null;
|
||||
|
||||
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
|
||||
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
|
||||
export function ModelsPage() {
|
||||
export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error", text: string) => void }) {
|
||||
const [tab, setTab] = useState<Tab>("all");
|
||||
const [items, setItems] = useState<ModelEntity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -115,6 +115,9 @@ export function ModelsPage() {
|
||||
try {
|
||||
await api.deleteModel(id);
|
||||
setItems((list) => list.filter((m) => m.id !== id));
|
||||
onNotify?.("success", "已移至垃圾桶");
|
||||
} catch (error) {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "删除失败");
|
||||
} finally {
|
||||
setConfirmId(null);
|
||||
}
|
||||
|
||||
+223
-114
@@ -1,111 +1,257 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { api } from "../api";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
import type { Asset, Product } from "../types";
|
||||
import type { Asset, FreeVideoTask, ImageConversation, ModelEntity, Product, Project } 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 || "";
|
||||
type TrashKind = "product" | "asset" | "model" | "image" | "freeVideo" | "project";
|
||||
|
||||
type TrashRow = {
|
||||
id: string;
|
||||
kind: TrashKind;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cover?: string;
|
||||
};
|
||||
|
||||
type TrashSection = {
|
||||
key: TrashKind;
|
||||
title: string;
|
||||
rows: TrashRow[];
|
||||
};
|
||||
|
||||
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 => {
|
||||
|
||||
const dateOf = (iso?: string | null): 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")}`;
|
||||
};
|
||||
|
||||
const coverOf = (p: Product): string => p.cover_preview_url || p.images?.find((i) => i.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 ASSET_TYPE_LABEL: Record<string, string> = { image: "图片", video: "视频", audio: "音频" };
|
||||
|
||||
// 垃圾桶:已软删商品(status=archived)+ 已软删资产(is_deleted,R108)· 均可恢复 / 可彻底删除(不可恢复)
|
||||
export function TrashPage({ onRestore, onPurge }: {
|
||||
const rowsFromProducts = (items: Product[]): TrashRow[] =>
|
||||
items.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "product",
|
||||
title: p.title,
|
||||
subtitle: p.category || "未分类",
|
||||
cover: coverOf(p)
|
||||
}));
|
||||
|
||||
const rowsFromAssets = (items: Asset[]): TrashRow[] =>
|
||||
items.map((a) => {
|
||||
const deletedAt = dateOf(a.updated_at);
|
||||
return {
|
||||
id: a.id,
|
||||
kind: "asset",
|
||||
title: a.name,
|
||||
subtitle: `${ASSET_TYPE_LABEL[a.asset_type] || a.asset_type}${deletedAt ? ` · 删除于 ${deletedAt}` : ""}`,
|
||||
cover: assetCoverOf(a)
|
||||
};
|
||||
});
|
||||
|
||||
const rowsFromModels = (items: ModelEntity[]): TrashRow[] =>
|
||||
items.map((m) => ({
|
||||
id: m.id,
|
||||
kind: "model",
|
||||
title: m.name,
|
||||
subtitle: m.source === "upload" ? "上传模特" : "AI 模特",
|
||||
cover: m.portrait || m.triview || ""
|
||||
}));
|
||||
|
||||
const rowsFromConversations = (items: ImageConversation[]): TrashRow[] =>
|
||||
items.map((c) => ({
|
||||
id: c.id,
|
||||
kind: "image",
|
||||
title: c.title || "未命名图片创作",
|
||||
subtitle: `${c.task_count || 0} 个生成任务${dateOf(c.updated_at) ? ` · 删除于 ${dateOf(c.updated_at)}` : ""}`
|
||||
}));
|
||||
|
||||
const rowsFromFreeVideos = (items: FreeVideoTask[]): TrashRow[] =>
|
||||
items.map((task) => ({
|
||||
id: task.id,
|
||||
kind: "freeVideo",
|
||||
title: task.prompt || "未命名视频创作",
|
||||
subtitle: `${task.status}${task.duration ? ` · ${task.duration}s` : ""}${task.aspect_ratio ? ` · ${task.aspect_ratio}` : ""}`,
|
||||
cover: task.thumbnail_url || ""
|
||||
}));
|
||||
|
||||
const rowsFromProjects = (items: Project[]): TrashRow[] =>
|
||||
items.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "project",
|
||||
title: p.name,
|
||||
subtitle: p.product_title || p.current_stage || p.status,
|
||||
cover: p.cover_preview_url || ""
|
||||
}));
|
||||
|
||||
export function TrashPage({ onRestore, onPurge, onChanged }: {
|
||||
navigate: NavigateFn;
|
||||
onRestore: (id: string) => Promise<unknown> | void;
|
||||
onPurge: (id: string) => Promise<unknown> | void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<Product[]>([]);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [sections, setSections] = useState<TrashSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [errText, setErrText] = useState("");
|
||||
const [bulkBusy, setBulkBusy] = useState<"restore" | "purge" | null>(null);
|
||||
const [purgeAllOpen, setPurgeAllOpen] = useState(false);
|
||||
const isEmpty = items.length === 0 && assets.length === 0;
|
||||
|
||||
const allRows = useMemo(() => sections.flatMap((section) => section.rows), [sections]);
|
||||
const isEmpty = allRows.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void Promise.allSettled([api.productsTrash(), api.assetsTrash()]).then(([p, a]) => {
|
||||
void Promise.allSettled([
|
||||
api.productsTrash(),
|
||||
api.assetsTrash(),
|
||||
api.modelsTrash(),
|
||||
api.conversationsTrash("image"),
|
||||
api.freeVideoTrash(0, 100),
|
||||
api.projectsTrash()
|
||||
]).then(([products, assets, models, conversations, freeVideos, projects]) => {
|
||||
if (!alive) return;
|
||||
setItems(p.status === "fulfilled" ? p.value.results : []);
|
||||
setAssets(a.status === "fulfilled" ? a.value.results : []);
|
||||
const rawSections: TrashSection[] = [
|
||||
{
|
||||
key: "product",
|
||||
title: "商品",
|
||||
rows: products.status === "fulfilled" ? rowsFromProducts(products.value.results) : []
|
||||
},
|
||||
{
|
||||
key: "asset",
|
||||
title: "资产",
|
||||
rows: assets.status === "fulfilled" ? rowsFromAssets(assets.value.results) : []
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
title: "模特",
|
||||
rows: models.status === "fulfilled" ? rowsFromModels(models.value.results) : []
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
title: "自由创作图片",
|
||||
rows: conversations.status === "fulfilled" ? rowsFromConversations(conversations.value.results) : []
|
||||
},
|
||||
{
|
||||
key: "freeVideo",
|
||||
title: "自由创作视频",
|
||||
rows: freeVideos.status === "fulfilled" ? rowsFromFreeVideos(freeVideos.value.results) : []
|
||||
},
|
||||
{
|
||||
key: "project",
|
||||
title: "视频项目",
|
||||
rows: projects.status === "fulfilled" ? rowsFromProjects(projects.value.results) : []
|
||||
}
|
||||
];
|
||||
const nextSections = rawSections.filter((section) => section.rows.length > 0);
|
||||
setSections(nextSections);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
async function restore(id: string) {
|
||||
setBusyId(id);
|
||||
try { await onRestore(id); setItems((list) => list.filter((p) => p.id !== id)); }
|
||||
finally { setBusyId(null); }
|
||||
function removeRow(row: TrashRow) {
|
||||
setSections((list) =>
|
||||
list
|
||||
.map((section) => section.key === row.kind ? { ...section, rows: section.rows.filter((item) => item.id !== row.id) } : section)
|
||||
.filter((section) => section.rows.length > 0)
|
||||
);
|
||||
}
|
||||
async function purge(id: string) {
|
||||
setBusyId(id);
|
||||
try { await onPurge(id); setItems((list) => list.filter((p) => p.id !== id)); setConfirmId(null); }
|
||||
finally { setBusyId(null); }
|
||||
|
||||
function rowKey(row: TrashRow): string {
|
||||
return `${row.kind}:${row.id}`;
|
||||
}
|
||||
// 资产的恢复/彻底删除直接走 api(商品那对经 App 的 action 包装,只发商品口)。失败不移行、如实亮错误。
|
||||
async function restoreAsset(id: string) {
|
||||
setBusyId(id);
|
||||
|
||||
function restoreRow(row: TrashRow): Promise<unknown> {
|
||||
if (row.kind === "product") return Promise.resolve(onRestore(row.id));
|
||||
if (row.kind === "asset") return api.restoreAsset(row.id);
|
||||
if (row.kind === "model") return api.restoreModel(row.id);
|
||||
if (row.kind === "image") return api.restoreConversation(row.id);
|
||||
if (row.kind === "freeVideo") return api.restoreFreeVideo(row.id);
|
||||
return api.restoreProject(row.id);
|
||||
}
|
||||
|
||||
function purgeRow(row: TrashRow): Promise<unknown> {
|
||||
if (row.kind === "product") return Promise.resolve(onPurge(row.id));
|
||||
if (row.kind === "asset") return api.purgeAsset(row.id);
|
||||
if (row.kind === "model") return api.purgeModel(row.id);
|
||||
if (row.kind === "image") return api.purgeConversation(row.id);
|
||||
if (row.kind === "freeVideo") return api.purgeFreeVideo(row.id);
|
||||
return api.purgeProject(row.id);
|
||||
}
|
||||
|
||||
async function restore(row: TrashRow) {
|
||||
setBusyKey(rowKey(row));
|
||||
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); }
|
||||
try {
|
||||
await restoreRow(row);
|
||||
removeRow(row);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
setErrText(error instanceof Error ? error.message : "恢复失败");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
async function purgeAsset(id: string) {
|
||||
setBusyId(id);
|
||||
|
||||
async function purge(row: TrashRow) {
|
||||
setBusyKey(rowKey(row));
|
||||
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); }
|
||||
try {
|
||||
await purgeRow(row);
|
||||
removeRow(row);
|
||||
setConfirmId(null);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
setErrText(error instanceof Error ? error.message : "彻底删除失败");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
// 全部恢复:商品走 onRestore、资产走 api.restoreAsset,各自独立通道并发跑;失败的留在列表、错误如实亮出
|
||||
// 按 id 而非下标比对结果:防止执行期间单条操作改动了 items/assets 导致下标错位
|
||||
async function restoreAll() {
|
||||
setBulkBusy("restore");
|
||||
setErrText("");
|
||||
const itemIds = items.map((p) => p.id);
|
||||
const assetIds = assets.map((a) => a.id);
|
||||
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onRestore(id))));
|
||||
const assetResults = await Promise.allSettled(assetIds.map((id) => api.restoreAsset(id)));
|
||||
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
|
||||
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
|
||||
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
|
||||
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
|
||||
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
const rows = [...allRows];
|
||||
const results = await Promise.allSettled(rows.map((row) => restoreRow(row)));
|
||||
const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey));
|
||||
setSections((list) =>
|
||||
list
|
||||
.map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) }))
|
||||
.filter((section) => section.rows.length > 0)
|
||||
);
|
||||
const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分恢复失败");
|
||||
if (results.some((r) => r.status === "fulfilled")) onChanged?.();
|
||||
setBulkBusy(null);
|
||||
}
|
||||
// 清空垃圾桶:同上,走各自的彻底删除通道
|
||||
|
||||
async function purgeAll() {
|
||||
setPurgeAllOpen(false);
|
||||
setBulkBusy("purge");
|
||||
setErrText("");
|
||||
const itemIds = items.map((p) => p.id);
|
||||
const assetIds = assets.map((a) => a.id);
|
||||
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onPurge(id))));
|
||||
const assetResults = await Promise.allSettled(assetIds.map((id) => api.purgeAsset(id)));
|
||||
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
|
||||
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
|
||||
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
|
||||
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
|
||||
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
const rows = [...allRows];
|
||||
const results = await Promise.allSettled(rows.map((row) => purgeRow(row)));
|
||||
const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey));
|
||||
setSections((list) =>
|
||||
list
|
||||
.map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) }))
|
||||
.filter((section) => section.rows.length > 0)
|
||||
);
|
||||
const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分删除失败");
|
||||
if (results.some((r) => r.status === "fulfilled")) onChanged?.();
|
||||
setBulkBusy(null);
|
||||
}
|
||||
|
||||
@@ -115,7 +261,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>
|
||||
@@ -123,45 +269,45 @@ export function TrashPage({ onRestore, onPurge }: {
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => setPurgeAllOpen(true)}>清空垃圾桶</button>
|
||||
<button className="btn btn-primary" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => void restoreAll()}>
|
||||
{bulkBusy === "restore" ? "恢复中…" : "全部恢复"}
|
||||
{bulkBusy === "restore" ? "恢复中..." : "全部恢复"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中…</span></div>
|
||||
) : items.length === 0 && assets.length === 0 ? (
|
||||
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中...</span></div>
|
||||
) : isEmpty ? (
|
||||
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></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>
|
||||
{sections.map((section) => (
|
||||
<div className="trash-section" key={section.key}>
|
||||
<div className="trash-section-title mono">// {section.title} · {section.rows.length}</div>
|
||||
<div className="trash-list">
|
||||
{items.map((p) => {
|
||||
const cover = coverOf(p);
|
||||
const busy = busyId === p.id;
|
||||
{section.rows.map((row) => {
|
||||
const key = rowKey(row);
|
||||
const busy = busyKey === key;
|
||||
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 className="trash-row" key={key}>
|
||||
<div className={`placeholder trash-thumb${row.cover ? " has-mock-media" : ""}`} style={row.cover ? mediaStyle(row.cover) : undefined}>
|
||||
{!row.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 className="trash-name" title={row.title}>{row.title}</div>
|
||||
<div className="trash-sub mono">// {row.subtitle}</div>
|
||||
</div>
|
||||
<div className="trash-actions">
|
||||
{confirmId === p.id ? (
|
||||
{confirmId === key ? (
|
||||
<>
|
||||
<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 trash-del" type="button" disabled={busy} onClick={() => void purge(row)}>确认删除</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>
|
||||
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(row)}>恢复</button>
|
||||
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(key)}>彻底删除</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -170,44 +316,7 @@ export function TrashPage({ onRestore, onPurge }: {
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ export type Product = {
|
||||
specs?: Record<string, unknown>;
|
||||
description: string;
|
||||
status: string;
|
||||
purged_at?: string | null;
|
||||
cover_asset?: string | null;
|
||||
cover_preview_url?: string;
|
||||
images?: Array<{ id: string; asset: string; preview_url?: string; sort_order: number; is_primary: boolean }>;
|
||||
@@ -239,6 +240,8 @@ export type Asset = {
|
||||
description: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
origin_task?: string | null;
|
||||
is_deleted?: boolean;
|
||||
purged_at?: string | null;
|
||||
// 是否在「资产库」列表展示:图片生成的成图自动 true(R109);演员立绘暂存 false,「保存人物」后置 true
|
||||
in_library?: boolean;
|
||||
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
|
||||
@@ -267,6 +270,8 @@ export type ModelEntity = {
|
||||
source: "ai" | "upload";
|
||||
description: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
is_deleted?: boolean;
|
||||
purged_at?: string | null;
|
||||
portrait_asset: string | null;
|
||||
triview_asset: string | null;
|
||||
voice_asset: string | null;
|
||||
@@ -446,6 +451,8 @@ export type Project = {
|
||||
status: string;
|
||||
current_stage: string;
|
||||
failure_reason: string;
|
||||
is_deleted?: boolean;
|
||||
purged_at?: string | null;
|
||||
// 轻量列表序列化(ProjectListSerializer)专有字段;详情序列化不含,故可选
|
||||
product_title?: string;
|
||||
cover_preview_url?: string;
|
||||
@@ -576,6 +583,8 @@ export type FreeVideoTask = {
|
||||
estimated_cost: string;
|
||||
actual_cost: string;
|
||||
error_message: string;
|
||||
is_deleted?: boolean;
|
||||
purged_at?: string | null;
|
||||
fallback_note?: string;
|
||||
is_favorited: boolean;
|
||||
video_url: string;
|
||||
@@ -625,6 +634,8 @@ export type AITask = {
|
||||
batch_id?: string | null;
|
||||
mode?: string | null;
|
||||
status: string;
|
||||
is_deleted?: boolean;
|
||||
purged_at?: string | null;
|
||||
idempotency_key?: string;
|
||||
provider_task_id?: string;
|
||||
created_at: string;
|
||||
@@ -638,6 +649,8 @@ export type ImageConversation = {
|
||||
mode: "image" | "model" | "cover";
|
||||
product: string | null;
|
||||
task_count: number;
|
||||
is_deleted?: boolean;
|
||||
purged_at?: string | null;
|
||||
last_active_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Reference in New Issue
Block a user