feat(core): Wave 3 batch4 — 外壳账户菜单 + 设置dirty引擎 + 商品创建抽屉多图

全局外壳(sub-agent):侧栏 .user 点击弹账户菜单(头像+店名+用户名[非邮箱,认证定调]+
  个人设置/消息/团队/消费/退出),复用现成 .shell-account-menu CSS,点外部/ESC 收起
设置页(sub-agent):dirty-state 引擎(baseline+draft diff 13字段,顶栏「保存所有变更」
  无改动 disabled、改动激活+N项计数、统一保存、beforeunload、nav dirty-dot);修一处类型错(|| {} → 可选链)
商品创建抽屉(sub-agent):主图多图≤5+.pf-grid缩略网格+单张删、submit主图/卖点必填校验+收编bulletDraft、
  上传区拖拽变橙、卖点字重对齐;内联 style 抽进 css

验证: tsc 0 · build 0 · 走查 账户菜单5项+ESC、设置保存按钮无改动disabled、抽屉multiple+pf-grid 全过 · 0 console error

⚠️ 外壳:顶栏头像菜单接线需改 App.tsx/pipeline.tsx(超出单文件范围),侧栏入口已全可用。移交监督。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 05:29:23 +08:00
co-authored by Claude Opus 4.8
parent da8c6a70a7
commit 378b0a350c
6 changed files with 629 additions and 132 deletions
+129 -4
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { createPortal } from "react-dom";
import { AlertCircle, Check, Info } from "lucide-react";
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";
@@ -96,6 +98,93 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: (
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[] = [
@@ -130,7 +219,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
settingsNotify: "settings"
};
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal }: {
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal, logout }: {
page: Page;
navigate: Navigate;
user: User;
@@ -139,6 +228,9 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
projects: Project[];
productTotal?: number;
projectTotal?: number;
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
logout?: () => void;
}) {
const activeNav = PAGE_TO_NAV[page];
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
@@ -164,6 +256,20 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
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);
@@ -225,13 +331,32 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
))}
</nav>
<div className="aside-foot">
<div className="user">
<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}
/>
)}
</>
);
}