后端(无需迁移,复用 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>
433 lines
20 KiB
TypeScript
433 lines
20 KiB
TypeScript
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { AlertCircle, Check, Info, LogOut } from "lucide-react";
|
|
import { IconKitSvg } from "./IconKitSvg";
|
|
import { useBodyScrollLock } from "./overlays";
|
|
import { api, setToken } from "../api";
|
|
import type { Product, Project, Team, User } from "../types";
|
|
import type { Notice, Page } from "../routes/route-config";
|
|
|
|
const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
|
|
|
|
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
|
|
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
|
|
const SHELL_COMMANDS: Command[] = [
|
|
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
|
|
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
|
|
{ id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" },
|
|
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
|
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
|
|
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
|
|
{ id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
|
|
{ id: "settings", group: "导航", label: "设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" },
|
|
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
|
|
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
|
|
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
|
|
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
|
|
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
|
|
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑、加入资产库", page: "imageOptimize", icon: "images" }
|
|
];
|
|
|
|
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
|
|
const [query, setQuery] = useState("");
|
|
useBodyScrollLock(open);
|
|
useEffect(() => { if (open) setQuery(""); }, [open]);
|
|
const items = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
return SHELL_COMMANDS.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
|
|
}, [query]);
|
|
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
|
|
if (!open) return null;
|
|
let lastGroup = "";
|
|
return createPortal(
|
|
<div
|
|
id="shell-command-bg"
|
|
className="shell-command-bg show"
|
|
aria-hidden="false"
|
|
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
|
>
|
|
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
|
|
<div className="shell-command-h">
|
|
<span className="ic"><IconKitSvg name="search" /></span>
|
|
<input
|
|
id="shell-command-input"
|
|
autoFocus
|
|
placeholder="搜索页面、动作…"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") { event.preventDefault(); onClose(); }
|
|
else if (event.key === "Enter" && items[0]) { event.preventDefault(); run(items[0]); }
|
|
}}
|
|
/>
|
|
<span id="shell-command-count" className="shell-command-count">{items.length} 项</span>
|
|
<button id="shell-command-close" type="button" className="shell-command-close" aria-label="关闭" onClick={onClose}>Esc</button>
|
|
</div>
|
|
<div id="shell-command-list" className="shell-command-list">
|
|
{items.length === 0 && (
|
|
<div className="shell-command-empty">
|
|
<IconKitSvg name="search" />
|
|
<span>没有匹配的入口</span>
|
|
<span className="shell-command-section">// 换个关键词试试</span>
|
|
</div>
|
|
)}
|
|
{items.map((cmd, i) => {
|
|
const section = cmd.group !== lastGroup ? <div className="shell-command-section">{cmd.group}</div> : null;
|
|
lastGroup = cmd.group;
|
|
return (
|
|
<div key={cmd.id}>
|
|
{section}
|
|
<button className={`shell-command-item${i === 0 ? " active" : ""}`} type="button" onClick={() => run(cmd)}>
|
|
<span className="cmd-ic"><IconKitSvg name={cmd.icon} /></span>
|
|
<span className="cmd-main">
|
|
<span className="cmd-title">{cmd.label}</span>
|
|
<span className="cmd-sub">{cmd.sub}</span>
|
|
</span>
|
|
{cmd.key && <span className="cmd-key">{cmd.key}</span>}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
type Navigate = (page: Page) => void;
|
|
|
|
// 账户下拉菜单 —— 忠实搬设计稿 shell.js 的 accountMenuHtml + toggleAccountMenu 定位逻辑。
|
|
// 触发器(侧栏 .user / 顶栏头像)传入自己的 DOMRect 当锚点;菜单 fixed 定位、右对齐锚点、
|
|
// 越界自动翻到锚点上方,并钳进视口。点外部 / Esc 收起。各项 navigate,退出走 logout。
|
|
// 认证定调:头像下方第二行展示「用户名」(user.username),本项目不用邮箱。
|
|
const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [
|
|
{ act: "settings", icon: "settings", label: "个人设置" },
|
|
{ act: "messages", icon: "bell", label: "消息中心" },
|
|
{ act: "team", icon: "users", label: "团队管理" },
|
|
{ act: "account", icon: "creditCard", label: "消费与余额" }
|
|
];
|
|
|
|
function AccountMenu({ anchorRect, onClose, navigate, logout, user, team }: {
|
|
anchorRect: DOMRect;
|
|
onClose: () => void;
|
|
navigate: Navigate;
|
|
logout: () => void;
|
|
user: User;
|
|
team: Team | null;
|
|
}) {
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 });
|
|
|
|
// 量出真实菜单尺寸后再定位(右对齐锚点、上下翻转防溢出),与 V1 toggleAccountMenu 一致
|
|
useLayoutEffect(() => {
|
|
const menu = menuRef.current;
|
|
const width = menu?.offsetWidth || 232;
|
|
const height = menu?.offsetHeight || 260;
|
|
let left = anchorRect.right - width;
|
|
if (left < 12) left = anchorRect.left;
|
|
left = Math.min(Math.max(12, left), window.innerWidth - width - 12);
|
|
let top = anchorRect.bottom + 8;
|
|
if (top + height > window.innerHeight - 12) top = Math.max(12, anchorRect.top - height - 8);
|
|
setPos({ left, top });
|
|
}, [anchorRect]);
|
|
|
|
// 点菜单外部 / Esc → 收起(锚点本身由触发器的 toggle 处理,避免点锚点立刻又被关掉)
|
|
useEffect(() => {
|
|
const onDown = (event: MouseEvent) => {
|
|
const target = event.target as Node;
|
|
if (menuRef.current?.contains(target)) return;
|
|
if ((target as Element).closest?.(".user, .topbar-avatar")) return;
|
|
onClose();
|
|
};
|
|
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); };
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
|
const go = (page: Page) => { onClose(); navigate(page); };
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
className="shell-account-menu show"
|
|
id="shell-account-menu"
|
|
role="menu"
|
|
aria-label="账户菜单"
|
|
style={{ left: pos.left, top: pos.top }}
|
|
>
|
|
<div className="shell-account-head">
|
|
<span className="av">{avatar}</span>
|
|
<span>
|
|
<span className="nm">{team?.name || user.username}</span>
|
|
<span className="mail">{user.username}</span>
|
|
</span>
|
|
</div>
|
|
{ACCOUNT_ITEMS.map((item) => (
|
|
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
|
|
<IconKitSvg name={item.icon} />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
<div className="sep" />
|
|
<button type="button" role="menuitem" onClick={() => { onClose(); logout(); }}>
|
|
<LogOut size={14} />
|
|
退出登录
|
|
</button>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
|
|
|
|
const NAV: NavDef[] = [
|
|
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
|
|
{ id: "products", page: "products", label: "商品库", icon: "package" },
|
|
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
|
|
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
|
|
{ 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" }
|
|
];
|
|
|
|
const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
|
dashboard: "dashboard",
|
|
products: "products",
|
|
productDetail: "products",
|
|
productCreateUpload: "products",
|
|
projects: "projects",
|
|
projectWizard: "projects",
|
|
pipeline: "projects",
|
|
assetFactory: "assetFactory",
|
|
imageOptimize: "assetFactory",
|
|
modelPhoto: "assetFactory",
|
|
modelPhotoDemoA: "assetFactory",
|
|
modelPhotoDemoB: "assetFactory",
|
|
platformCover: "assetFactory",
|
|
library: "library",
|
|
team: "team",
|
|
account: "account",
|
|
settings: "settings",
|
|
settingsNotify: "settings"
|
|
};
|
|
|
|
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal, logout, onOpenAdmin }: {
|
|
page: Page;
|
|
navigate: Navigate;
|
|
user: User;
|
|
team: Team | null;
|
|
products: Product[];
|
|
projects: Project[];
|
|
productTotal?: number;
|
|
projectTotal?: number;
|
|
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
|
|
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
|
|
logout?: () => void;
|
|
// 平台超管入口:仅 user.is_platform_admin 时显示,点了进 /admin 后台
|
|
onOpenAdmin?: () => void;
|
|
}) {
|
|
const activeNav = PAGE_TO_NAV[page];
|
|
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
|
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
|
|
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
|
|
|
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
|
|
// 类(CSS 在 design-restraint.css),并持久化到 localStorage。
|
|
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1");
|
|
useEffect(() => {
|
|
document.body.classList.toggle("sidebar-collapsed", collapsed);
|
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
|
|
return () => document.body.classList.remove("sidebar-collapsed");
|
|
}, [collapsed]);
|
|
|
|
// 移动端导航抽屉:窄屏侧栏默认收起(display:none),靠左上汉堡键拉出为浮层 + 背景遮罩。
|
|
// 点导航项 / 遮罩 / Esc 关闭。桌面端(>1100px)汉堡键 CSS 隐藏,不影响原有收窄逻辑。
|
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
|
useEffect(() => {
|
|
if (!mobileNavOpen) return;
|
|
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setMobileNavOpen(false); };
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [mobileNavOpen]);
|
|
|
|
// 账户下拉菜单:点侧栏 .user 用其 DOMRect 当锚点展开,再次点击收起(toggle)。
|
|
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
|
|
const toggleAccountMenu = (event: ReactMouseEvent<HTMLElement>) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
setAccountAnchor((prev) => (prev ? null : rect));
|
|
};
|
|
// 退出兜底:父级未传 logout 时,自行 best-effort 调登出接口 + 清 token + 跳登录页。
|
|
const handleLogout = () => {
|
|
if (logout) { logout(); return; }
|
|
void api.logout().catch(() => undefined);
|
|
setToken(null);
|
|
window.location.href = "/login";
|
|
};
|
|
|
|
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
|
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
const openCommandPalette = () => setPaletteOpen(true);
|
|
useEffect(() => {
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
event.preventDefault();
|
|
setPaletteOpen((value) => !value);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{/* 移动端汉堡键(桌面 CSS 隐藏)+ 抽屉遮罩 */}
|
|
<button className="mobile-nav-btn" type="button" aria-label="打开导航" aria-expanded={mobileNavOpen} onClick={() => setMobileNavOpen(true)}>
|
|
<span /><span /><span />
|
|
</button>
|
|
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
|
|
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
|
|
<div className="sidebar-head">
|
|
<a className="brand" href="/dashboard" aria-label="Airshelf 工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
|
|
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
|
|
</a>
|
|
</div>
|
|
<button
|
|
className="sidebar-toggle"
|
|
type="button"
|
|
aria-pressed={collapsed}
|
|
aria-label={collapsed ? "展开导航" : "收窄导航"}
|
|
title={collapsed ? "展开导航" : "收窄导航"}
|
|
onClick={() => setCollapsed((value) => !value)}
|
|
>
|
|
<span className="sidebar-toggle-icon sidebar-toggle-icon--collapse"><IconKitSvg name="chevronLeft" size={18} strokeWidth={1.8} /></span>
|
|
<span className="sidebar-toggle-icon sidebar-toggle-icon--expand"><IconKitSvg name="chevronRight" size={18} strokeWidth={1.8} /></span>
|
|
</button>
|
|
<div className="search-box" title="搜索 (Ctrl K)" role="button" tabIndex={0} onClick={openCommandPalette} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openCommandPalette(); } }}>
|
|
<IconKitSvg name="search" />
|
|
<input id="global-search" placeholder="搜索" readOnly aria-label="打开全局搜索" />
|
|
<span className="kbd">Ctrl K</span>
|
|
</div>
|
|
<div className="nav-section">主要</div>
|
|
<nav>
|
|
{NAV.map((item) => (
|
|
<a
|
|
key={item.id}
|
|
href={`/${item.id}`}
|
|
className={activeNav === item.page ? "active" : ""}
|
|
title={item.label}
|
|
aria-label={item.label}
|
|
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate(item.page); }}
|
|
>
|
|
<IconKitSvg name={item.icon} />
|
|
<span>{item.label}</span>
|
|
{badges[item.id] !== undefined && <span className="pill-mini">{badges[item.id]}</span>}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
{user.is_platform_admin && onOpenAdmin && (
|
|
<>
|
|
<div className="nav-section">平台</div>
|
|
<nav>
|
|
<a
|
|
href="/admin"
|
|
title="平台后台"
|
|
aria-label="平台后台"
|
|
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); onOpenAdmin(); }}
|
|
>
|
|
<IconKitSvg name="shield" />
|
|
<span>平台后台</span>
|
|
</a>
|
|
</nav>
|
|
</>
|
|
)}
|
|
<div className="aside-foot">
|
|
<div
|
|
className="user"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-haspopup="menu"
|
|
aria-expanded={accountAnchor !== null}
|
|
title="账户"
|
|
onClick={toggleAccountMenu}
|
|
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleAccountMenu(event as unknown as ReactMouseEvent<HTMLElement>); } }}
|
|
>
|
|
<div className="av">{avatar}</div>
|
|
<div className="em">{team?.name || user.username}</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
|
|
{accountAnchor && (
|
|
<AccountMenu
|
|
anchorRect={accountAnchor}
|
|
onClose={() => setAccountAnchor(null)}
|
|
navigate={navigate}
|
|
logout={handleLogout}
|
|
user={user}
|
|
team={team}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function Decorations() {
|
|
// 与设计稿 shell.js 一致:grid-bg 底纹 + 4 个 sq-mark(写死坐标,不压内容)
|
|
return (
|
|
<>
|
|
<div className="grid-bg" />
|
|
<span className="sq-mark" style={{ top: 238, left: 478 }} />
|
|
<span className="sq-mark" style={{ top: 478, left: 1198 }} />
|
|
<span className="sq-mark" style={{ bottom: 300, left: 238 }} />
|
|
<span className="sq-mark" style={{ top: 718, right: 240 }} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function CornerMarks() {
|
|
return (
|
|
<>
|
|
<CornerMark pos="tl" />
|
|
<CornerMark pos="tr" />
|
|
<CornerMark pos="bl" />
|
|
<CornerMark pos="br" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ToastLike({ notice }: { notice: NonNullable<Notice> }) {
|
|
// 右下角浮层:统一用设计系统 .toast(单橙 icon-box + --shadow-floating),不再随
|
|
// success/error 变绿/红边(单一 accent 铁律)。挂载后下一帧上 .show 触发滑入进场。
|
|
const [show, setShow] = useState(false);
|
|
useEffect(() => {
|
|
const raf = requestAnimationFrame(() => setShow(true));
|
|
return () => cancelAnimationFrame(raf);
|
|
}, []);
|
|
const Icon = notice.type === "error" ? AlertCircle : notice.type === "info" ? Info : Check;
|
|
return (
|
|
<div className={`toast${show ? " show" : ""}`} role="status" aria-live="polite">
|
|
<div className="ic-t"><Icon size={13} /></div>
|
|
<div className="txt">{notice.text}<span className="mono">[ {notice.type.toUpperCase()} ]</span></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CornerMark({ pos }: { pos: "tl" | "tr" | "bl" | "br" }) {
|
|
return (
|
|
<span className={`corner-mark ${pos}`}>
|
|
<svg viewBox="0 0 22 21" fill="none">
|
|
<path d="M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z" fill="currentColor" />
|
|
</svg>
|
|
</span>
|
|
);
|
|
}
|