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"] search_fields = ["title", "brand", "category"]
ordering_fields = ["created_at", "updated_at", "title"] 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]) @action(detail=True, methods=["post"], url_path="images", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic @transaction.atomic
def upload_image(self, request, pk=None): def upload_image(self, request, pk=None):
+12 -3
View File
@@ -40,6 +40,7 @@ import {
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config"; import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config"; import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { AdminApp } from "./routes/admin/admin-app"; import { AdminApp } from "./routes/admin/admin-app";
import { TrashPage } from "./routes/trash";
import { money } from "./routes/stage-config"; import { money } from "./routes/stage-config";
const crumbLabels: Partial<Record<Page, string>> = { const crumbLabels: Partial<Record<Page, string>> = {
@@ -651,7 +652,7 @@ export function App() {
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")} onCreate={(payload) => action(() => api.createProduct(payload), "")}
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")}
/> />
); );
case "productCreateUpload": case "productCreateUpload":
@@ -666,12 +667,12 @@ export function App() {
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")} onCreate={(payload) => action(() => api.createProduct(payload), "")}
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")}
autoOpenCreate autoOpenCreate
/> />
); );
case "productDetail": case "productDetail":
if (!activeProduct) return <ProductsPage products={products} loading={!dataLoaded} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; if (!activeProduct) return <ProductsPage products={products} loading={!dataLoaded} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} />;
return ( return (
<ProductDetailPage <ProductDetailPage
product={activeProduct} product={activeProduct}
@@ -744,6 +745,14 @@ export function App() {
); );
case "library": case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "trash":
return (
<TrashPage
navigate={navigate}
onRestore={(id) => action(() => api.restoreProduct(id), "已恢复到商品库")}
onPurge={(id) => action(() => api.purgeProduct(id), "已彻底删除")}
/>
);
case "account": case "account":
return ( return (
<AccountPage <AccountPage
+11
View File
@@ -229,8 +229,19 @@ export const api = {
return request<Product>(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" }); return request<Product>(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" });
}, },
deleteProduct(id: string) { deleteProduct(id: string) {
// 软删 = 进垃圾桶(后端 status→archived,可恢复)
return request<void>(`/api/products/${id}/`, { method: "DELETE" }); return request<void>(`/api/products/${id}/`, { method: "DELETE" });
}, },
productsTrash() {
return request<Paginated<Product>>("/api/products/trash/");
},
restoreProduct(id: string) {
return request<Product>(`/api/products/${id}/restore/`, { method: "POST" });
},
purgeProduct(id: string) {
// 彻底删除(不可恢复)
return request<void>(`/api/products/${id}/purge/`, { method: "DELETE" });
},
projects() { projects() {
return request<Paginated<Project>>("/api/projects/"); return request<Paginated<Project>>("/api/projects/");
}, },
@@ -2,6 +2,7 @@
const iconPaths: Record<string, string> = { const iconPaths: Record<string, string> = {
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>', layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>', package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>', clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>', sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
@@ -195,6 +195,7 @@ const NAV: NavDef[] = [
{ id: "library", page: "library", label: "资产库", icon: "library" }, { id: "library", page: "library", label: "资产库", icon: "library" },
{ id: "team", page: "team", label: "团队", icon: "users" }, { id: "team", page: "team", label: "团队", icon: "users" },
{ id: "account", page: "account", label: "消费", icon: "creditCard" }, { id: "account", page: "account", label: "消费", icon: "creditCard" },
{ id: "trash", page: "trash", label: "垃圾桶", icon: "trash" },
{ id: "settings", page: "settings", label: "设置", icon: "settings" } { id: "settings", page: "settings", label: "设置", icon: "settings" }
]; ];
+1 -1
View File
@@ -275,7 +275,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
title="删除商品" title="删除商品"
subtitle="// DELETE ITEM" subtitle="// DELETE ITEM"
icon={<Trash2 size={16} />} icon={<Trash2 size={16} />}
detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?该操作不可撤销,商品的图册与关联记录也会一并移除。`} detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
confirmText="删除" confirmText="删除"
onCancel={() => setConfirmIds(null)} onCancel={() => setConfirmIds(null)}
onConfirm={doDelete} onConfirm={doDelete}
+7 -2
View File
@@ -31,7 +31,8 @@ export type Page =
| "modelPhotoDemoB" | "modelPhotoDemoB"
| "platformCover" | "platformCover"
| "settings" | "settings"
| "settingsNotify"; | "settingsNotify"
| "trash";
export type AuthMode = "login" | "register"; export type AuthMode = "login" | "register";
export type ResolvedRoute = { export type ResolvedRoute = {
@@ -89,7 +90,8 @@ export const routeLabels: Record<Page, string> = {
modelPhotoDemoB: "模特图方案 B", modelPhotoDemoB: "模特图方案 B",
platformCover: "平台套图", platformCover: "平台套图",
settings: "设置", settings: "设置",
settingsNotify: "通知设置" settingsNotify: "通知设置",
trash: "垃圾桶"
}; };
export const routePages = Object.keys(routeLabels) as Page[]; export const routePages = Object.keys(routeLabels) as Page[];
@@ -147,6 +149,7 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/platform-cover") return { page: "platformCover", authMode: "login", hash }; if (path === "/platform-cover") return { page: "platformCover", authMode: "login", hash };
if (path === "/settings/notify") return { page: "settingsNotify", authMode: "login", hash }; if (path === "/settings/notify") return { page: "settingsNotify", authMode: "login", hash };
if (path === "/settings") return { page: "settings", authMode: "login", hash }; if (path === "/settings") return { page: "settings", authMode: "login", hash };
if (path === "/trash") return { page: "trash", authMode: "login", hash };
return { page: "dashboard", authMode: "login", hash }; return { page: "dashboard", authMode: "login", hash };
} }
@@ -190,6 +193,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/settings/notify"; return "/settings/notify";
case "settings": case "settings":
return "/settings"; return "/settings";
case "trash":
return "/trash";
default: default:
return "/dashboard"; return "/dashboard";
} }
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import { api } from "../api";
import type { NavigateFn } from "./route-config";
import type { Product } from "../types";
import "../trash-page.css";
const coverOf = (p: Product): string => p.cover_preview_url || p.images?.find((i) => i.preview_url)?.preview_url || "";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// 垃圾桶:已软删(status=archived)的商品 · 可恢复 / 可彻底删除(不可恢复)
export function TrashPage({ onRestore, onPurge }: {
navigate: NavigateFn;
onRestore: (id: string) => Promise<unknown> | void;
onPurge: (id: string) => Promise<unknown> | void;
}) {
const [items, setItems] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
useEffect(() => {
let alive = true;
api.productsTrash().then((r) => { if (alive) setItems(r.results); }).catch(() => { if (alive) setItems([]); }).finally(() => { if (alive) 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); }
}
async function purge(id: string) {
setBusyId(id);
try { await onPurge(id); setItems((list) => list.filter((p) => p.id !== id)); setConfirmId(null); }
finally { setBusyId(null); }
}
return (
<div className="trash-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 已删除商品</span>
<span>·</span>
<span>,()</span>
</div>
</div>
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中…</span></div>
) : items.length === 0 ? (
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
) : (
<div className="trash-list">
{items.map((p) => {
const cover = coverOf(p);
const busy = busyId === p.id;
return (
<div className="trash-row" key={p.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame"></span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={p.title}>{p.title}</div>
<div className="trash-sub mono">// {p.category || "未分类"}</div>
</div>
<div className="trash-actions">
{confirmId === p.id ? (
<>
<span className="trash-confirm-txt mono">?</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purge(p.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}></button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(p.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(p.id)}></button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
/* 垃圾桶 · 已删除商品(软删 status=archived)· 仅用 design-restraint token */
.trash-empty { min-height: 220px; flex-direction: column; gap: 10px; }
.trash-list { display: flex; flex-direction: column; gap: 10px; }
.trash-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
}
.trash-thumb { width: 52px; height: 52px; flex: 0 0 52px; border-radius: var(--r-sm); }
.trash-meta { flex: 1 1 auto; min-width: 0; }
.trash-name { font-size: 14px; font-weight: 500; color: var(--accent-black); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-sub { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; letter-spacing: .02em; }
.trash-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.trash-confirm-txt { font-size: 12px; color: var(--accent-crimson); letter-spacing: .02em; }
/* 彻底删除确认按钮:危险态(复用全站 crimson token,不写裸 hex) */
.trash-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.trash-del:hover { filter: brightness(1.06); }