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
+1 -1
View File
@@ -275,7 +275,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
title="删除商品"
subtitle="// DELETE ITEM"
icon={<Trash2 size={16} />}
detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?该操作不可撤销,商品的图册与关联记录也会一并移除。`}
detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
confirmText="删除"
onCancel={() => setConfirmIds(null)}
onConfirm={doDelete}
+7 -2
View File
@@ -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<Page, string> = {
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";
}
+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>
);
}