diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py new file mode 100644 index 0000000..388b224 --- /dev/null +++ b/core/backend/apps/products/tests.py @@ -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()) diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index 65eaf4b..05e0f3e 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -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): diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index e5e35b9..1646db0 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -40,6 +40,7 @@ import { import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config"; import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config"; import { AdminApp } from "./routes/admin/admin-app"; +import { TrashPage } from "./routes/trash"; import { money } from "./routes/stage-config"; const crumbLabels: Partial> = { @@ -651,7 +652,7 @@ export function App() { openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} - onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} + onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} /> ); case "productCreateUpload": @@ -666,12 +667,12 @@ export function App() { openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} - onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} + onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} autoOpenCreate /> ); case "productDetail": - if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; + if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} />; return ( action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; + case "trash": + return ( + action(() => api.restoreProduct(id), "已恢复到商品库")} + onPurge={(id) => action(() => api.purgeProduct(id), "已彻底删除")} + /> + ); case "account": return ( (`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" }); }, deleteProduct(id: string) { + // 软删 = 进垃圾桶(后端 status→archived,可恢复) return request(`/api/products/${id}/`, { method: "DELETE" }); }, + productsTrash() { + return request>("/api/products/trash/"); + }, + restoreProduct(id: string) { + return request(`/api/products/${id}/restore/`, { method: "POST" }); + }, + purgeProduct(id: string) { + // 彻底删除(不可恢复) + return request(`/api/products/${id}/purge/`, { method: "DELETE" }); + }, projects() { return request>("/api/projects/"); }, diff --git a/core/frontend/src/components/IconKitSvg.tsx b/core/frontend/src/components/IconKitSvg.tsx index 615686e..261b17b 100644 --- a/core/frontend/src/components/IconKitSvg.tsx +++ b/core/frontend/src/components/IconKitSvg.tsx @@ -2,6 +2,7 @@ const iconPaths: Record = { layoutDashboard: '', + trash: '', package: '', clapperboard: '', sparkles: '', diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 222b7a5..dbf238f 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -195,6 +195,7 @@ const NAV: NavDef[] = [ { id: "library", page: "library", label: "资产库", icon: "library" }, { id: "team", page: "team", label: "团队", icon: "users" }, { id: "account", page: "account", label: "消费", icon: "creditCard" }, + { id: "trash", page: "trash", label: "垃圾桶", icon: "trash" }, { id: "settings", page: "settings", label: "设置", icon: "settings" } ]; diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index d01f734..ee90a4e 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -275,7 +275,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat title="删除商品" subtitle="// DELETE ITEM" icon={} - detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?该操作不可撤销,商品的图册与关联记录也会一并移除。`} + detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`} confirmText="删除" onCancel={() => setConfirmIds(null)} onConfirm={doDelete} diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 918c28e..64df92b 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -31,7 +31,8 @@ export type Page = | "modelPhotoDemoB" | "platformCover" | "settings" - | "settingsNotify"; + | "settingsNotify" + | "trash"; export type AuthMode = "login" | "register"; export type ResolvedRoute = { @@ -89,7 +90,8 @@ export const routeLabels: Record = { modelPhotoDemoB: "模特图方案 B", platformCover: "平台套图", settings: "设置", - settingsNotify: "通知设置" + settingsNotify: "通知设置", + trash: "垃圾桶" }; 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 === "/settings/notify") return { page: "settingsNotify", 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 }; } @@ -190,6 +193,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) { return "/settings/notify"; case "settings": return "/settings"; + case "trash": + return "/trash"; default: return "/dashboard"; } diff --git a/core/frontend/src/routes/trash.tsx b/core/frontend/src/routes/trash.tsx new file mode 100644 index 0000000..f74df0c --- /dev/null +++ b/core/frontend/src/routes/trash.tsx @@ -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 | void; + onPurge: (id: string) => Promise | void; +}) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [confirmId, setConfirmId] = useState(null); + const [busyId, setBusyId] = useState(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 ( +
+
+
+

垃圾桶

+
+ // 已删除商品 + · + 可恢复,或彻底删除(不可恢复) +
+
+
+ + {loading ? ( +
// 加载中…
+ ) : items.length === 0 ? ( +
// 垃圾桶是空的
+ ) : ( +
+ {items.map((p) => { + const cover = coverOf(p); + const busy = busyId === p.id; + return ( +
+
+ {!cover && 无图} +
+
+
{p.title}
+
// {p.category || "未分类"}
+
+
+ {confirmId === p.id ? ( + <> + 彻底删除?不可恢复 + + + + ) : ( + <> + + + + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/core/frontend/src/trash-page.css b/core/frontend/src/trash-page.css new file mode 100644 index 0000000..ff6e39b --- /dev/null +++ b/core/frontend/src/trash-page.css @@ -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); }