feat(trash): 商品删除→垃圾桶(软删可恢复)+ 左侧垃圾桶入口

后端(无需迁移,复用 Product.status active/archived):
- destroy 改软删(status→archived);正常列表/详情只看 active
- 新增 /products/trash(列已删)、/products/{id}/restore(恢复)、/{id}/purge(彻底删)
- tests.py:软删/列表隐藏/回收站/恢复/彻底删/拒绝彻底删 active —— 5 测试全过

前端:
- api:productsTrash / restoreProduct / purgeProduct
- 左侧导航加「垃圾桶」(新增 trash 图标);route-config 接 /trash 路由
- 新页 routes/trash.tsx + trash-page.css(仅 token):已删商品列表 + 恢复 + 彻底删(二次确认)
- 删除确认文案「不可撤销」→「移至垃圾桶可恢复」;toast「商品已删除」→「已移至垃圾桶」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-20 19:16:28 +08:00
co-authored by Claude Opus 4.8
parent b8fc1a0749
commit 3469799da5
10 changed files with 238 additions and 6 deletions
+53
View File
@@ -0,0 +1,53 @@
from rest_framework.test import APIClient
from django.test import TestCase
from apps.accounts.models import Team, TeamMember, User
from .models import Product
class ProductTrashTests(TestCase):
"""软删除 = 进垃圾桶(status=archived):正常列表只看 active,可恢复 / 可彻底删。"""
def setUp(self):
self.user = User.objects.create_user(username="u1", password="x")
self.team = Team.objects.create(name="T1", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="商品A")
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) # 软删,数据还在
# 正常列表不含已删
ids = [p["id"] for p in self.client.get("/api/products/").json()["results"]]
self.assertNotIn(str(self.product.id), ids)
def test_trash_lists_archived(self):
self.client.delete(f"/api/products/{self.product.id}/")
trash = self.client.get("/api/products/trash/").json()["results"]
self.assertIn(str(self.product.id), [p["id"] for p in trash])
def test_restore_back_to_active(self):
self.client.delete(f"/api/products/{self.product.id}/")
res = self.client.post(f"/api/products/{self.product.id}/restore/")
self.assertEqual(res.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.status, Product.Status.ACTIVE)
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):
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())
def test_purge_rejects_active(self):
# 未删除(active)商品不在垃圾桶 qs → 彻底删找不到
res = self.client.delete(f"/api/products/{self.product.id}/purge/")
self.assertEqual(res.status_code, 404)
self.assertTrue(Product.objects.filter(id=self.product.id).exists())
+37
View File
@@ -27,6 +27,43 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
search_fields = ["title", "brand", "category"]
ordering_fields = ["created_at", "updated_at", "title"]
def get_queryset(self):
# 软删除即「进垃圾桶」(status=archived):正常列表/详情只看 active;
# 回收站相关动作(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)
def perform_destroy(self, instance):
# 删除 = 软删进垃圾桶(可恢复),不真删数据
instance.status = Product.Status.ARCHIVED
instance.save(update_fields=["status", "updated_at"])
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已删除(archived)商品。"""
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):
"""从垃圾桶恢复:archived → active。"""
product = self.get_object()
product.status = Product.Status.ACTIVE
product.save(update_fields=["status", "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 商品可彻底删,不可恢复)。"""
product = self.get_object()
product.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"], url_path="images", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_image(self, request, pk=None):