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:
co-authored by
Claude Opus 4.8
parent
b8fc1a0749
commit
3469799da5
@@ -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<Record<Page, string>> = {
|
||||
@@ -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 <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 (
|
||||
<ProductDetailPage
|
||||
product={activeProduct}
|
||||
@@ -744,6 +745,14 @@ export function App() {
|
||||
);
|
||||
case "library":
|
||||
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":
|
||||
return (
|
||||
<AccountPage
|
||||
|
||||
@@ -229,8 +229,19 @@ export const api = {
|
||||
return request<Product>(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" });
|
||||
},
|
||||
deleteProduct(id: string) {
|
||||
// 软删 = 进垃圾桶(后端 status→archived,可恢复)
|
||||
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() {
|
||||
return request<Paginated<Project>>("/api/projects/");
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
|
||||
@@ -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" }
|
||||
];
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user