Files
2026-09-17 18:31:29 +08:00

739 lines
33 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AlertCircle, Boxes, Check, ChevronDown, ChevronRight, Info, LogOut, Settings, WalletCards } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { ConfirmModal, useBodyScrollLock, useOverlayTransition } from "./overlays";
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: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式创作", page: "omniCreate", icon: "sparkles", key: "O" },
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
{ 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: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
{ id: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
{ id: "video-replace", group: "导航", label: "视频复刻", sub: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", page: "videoReplace", icon: "replace", key: "E" },
{ 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: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
{ 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, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) {
const [query, setQuery] = useState("");
const { mounted, show } = useOverlayTransition(open, onClose, 220);
useBodyScrollLock(open);
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
const q = query.trim().toLowerCase();
// 非主账号:命令面板里也不暴露「团队」「消费」(与侧栏一致,PMC#3)
return SHELL_COMMANDS
.filter((cmd) => canManageBilling || (cmd.page !== "team" && cmd.page !== "account"))
.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
}, [query, canManageBilling]);
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
if (!mounted) return null;
let lastGroup = "";
return createPortal(
<div
id="shell-command-bg"
className={`shell-command-bg${show ? " show" : ""}`}
aria-hidden={!show}
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;
// 账户下拉菜单 —— 触发器为侧栏左下角 .org-switcher;菜单 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: "账户中心" }
];
export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
open: boolean;
anchorRect: DOMRect | null;
onClose: () => void;
navigate: Navigate;
logout: () => void;
user: User;
team: Team | null;
canManageBilling?: boolean;
}) {
const { mounted, show } = useOverlayTransition(open, undefined, 180);
const menuRef = useRef<HTMLDivElement>(null);
const lastRect = useRef<DOMRect | null>(anchorRect);
if (anchorRect) lastRect.current = anchorRect;
const rect = lastRect.current;
const [pos, setPos] = useState<{ left: number; top: number }>(() => (
rect ? { left: rect.left, top: rect.bottom + 8 } : { left: 0, top: 0 }
));
// 量出真实菜单尺寸后再定位(右对齐锚点、上下翻转防溢出),与 V1 toggleAccountMenu 一致
useLayoutEffect(() => {
if (!rect) return;
const menu = menuRef.current;
const width = menu?.offsetWidth || 232;
const height = menu?.offsetHeight || 260;
const fromLeft = rect.left < window.innerWidth / 2;
let left = fromLeft ? rect.left : rect.right - width;
left = Math.min(Math.max(12, left), window.innerWidth - width - 12);
let top = fromLeft ? rect.top - height - 8 : rect.bottom + 8;
if (top < 12 || top + height > window.innerHeight - 12) {
top = fromLeft
? Math.min(rect.bottom + 8, window.innerHeight - height - 12)
: Math.max(12, rect.top - height - 8);
}
setPos({ left, top });
}, [rect]);
// 点菜单外部 / Esc → 收起(锚点本身由触发器的 toggle 处理,避免点锚点立刻又被关掉)
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
const target = event.target as Node;
if (menuRef.current?.contains(target)) return;
if ((target as Element).closest?.(".org-switcher")) 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);
};
}, [open, onClose]);
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
const go = (page: Page) => { onClose(); navigate(page); };
const [confirmLogout, setConfirmLogout] = useState(false);
return (
<>
{mounted && rect ? createPortal(
<div
ref={menuRef}
className={`shell-account-menu${show ? " show" : ""}`}
id="shell-account-menu"
role="menu"
aria-label="账户菜单"
style={{ left: pos.left, top: pos.top }}
>
<div className="shell-account-head">
<span className="av">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatar}</span>
<span>
<span className="nm">{team?.name || user.username}</span>
<span className="mail">{user.username}</span>
</span>
</div>
{ACCOUNT_ITEMS.filter((item) => canManageBilling || (item.act !== "team" && item.act !== "account")).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(); setConfirmLogout(true); }}>
<LogOut size={14} />
退出登录
</button>
</div>,
document.body
) : null}
<ConfirmModal
open={confirmLogout}
title="退出当前账号"
icon={<LogOut size={16} />}
detail="确认后将退出当前设备,再次使用需要重新登录。项目、资产、团队成员与余额数据都会保留。"
confirmText="确认退出"
onCancel={() => setConfirmLogout(false)}
onConfirm={() => { setConfirmLogout(false); logout(); }}
/>
</>
);
}
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
const NAV: NavDef[] = [
{ id: "products", page: "products", label: "商品库", icon: "package" },
{ id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频创作", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" },
{ 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 RESOURCE_NAV: Array<{ id: string; page: Page; label: string }> = [
{ id: "products", page: "products", label: "商品库" },
{ id: "models", page: "models", label: "模特库" },
{ id: "library", page: "library", label: "成品库" },
{ id: "trash", page: "trash", label: "垃圾桶" },
];
type MajorNav = "resource" | "account" | "settings";
const MAJOR_META: Record<MajorNav, { label: string; icon: "boxes" | "wallet-cards" | "settings" }> = {
resource: { label: "资源中心", icon: "boxes" },
account: { label: "账户中心", icon: "wallet-cards" },
settings: { label: "系统设置", icon: "settings" },
};
function LiquidIcon({ name }: { name: "boxes" | "wallet-cards" | "settings" }) {
if (name === "boxes") return <Boxes size={20} strokeWidth={1.9} />;
if (name === "wallet-cards") return <WalletCards size={20} strokeWidth={1.9} />;
return <Settings size={20} strokeWidth={1.9} />;
}
export type TopModule = "workbench" | "omni" | "image" | "video";
const OPEN_PALETTE_EVENT = "airshelf:open-palette";
export function openCommandPalette() {
window.dispatchEvent(new Event(OPEN_PALETTE_EVENT));
}
export function topModuleForPage(page: Page): TopModule | null {
if (page === "dashboard") return "workbench";
if (page === "omniCreate" || page === "omniHistory" || page === "omniSession") return "omni";
if (
page === "assetFactory"
|| page === "imageOptimize"
|| page === "modelPhoto"
|| page === "modelPhotoDemoA"
|| page === "modelPhotoDemoB"
|| page === "platformCover"
) return "image";
if (page === "projects" || page === "projectWizard" || page === "quickCreate" || page === "pipeline" || page === "freeCreate" || page === "videoRemix" || page === "videoReplace") return "video";
return null;
}
const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
{ id: "omni", label: "全能创作", page: "omniCreate" },
{ id: "image", label: "图片创作", page: "assetFactory" },
{ id: "video", label: "视频创作", page: "projects" },
];
// 顶栏三按钮滑动黑胶囊 · 结构 / 类名 / moveModeIndicator 均按影擎 HTML 原样转写。
export function ModeTabs({ active, navigate }: { active: TopModule | null; navigate: Navigate }) {
const rootRef = useRef<HTMLElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
const activeRef = useRef(active);
const [visualActive, setVisualActive] = useState(active);
activeRef.current = visualActive ?? active;
useLayoutEffect(() => {
setVisualActive(active);
}, [active]);
const moveModeIndicator = (tab: HTMLButtonElement, animate = true) => {
const root = rootRef.current;
const modeIndicator = indicatorRef.current;
if (!root || !modeIndicator) return;
const modeTabs = [...root.querySelectorAll<HTMLButtonElement>(".mode-tab")];
const modeMaskRects = [...root.querySelectorAll<SVGRectElement>("#modeButtonMask rect")];
modeTabs.forEach((item, index) => {
const rect = modeMaskRects[index];
if (!rect) return;
rect.setAttribute("x", String(item.offsetLeft));
rect.setAttribute("y", "0");
rect.setAttribute("width", String(item.offsetWidth));
rect.setAttribute("height", String(item.offsetHeight));
});
if (!animate) modeIndicator.style.transition = "none";
else {
modeIndicator.style.transition = "transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1)";
}
modeIndicator.style.width = `${tab.offsetWidth}px`;
modeIndicator.style.transform = `translateX(${tab.offsetLeft}px)`;
if (!animate) {
void modeIndicator.offsetWidth;
modeIndicator.style.transition = "transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1)";
}
};
const tabFor = (id: TopModule | null) => {
const root = rootRef.current;
if (!root || !id) return null;
const idx = MODE_TABS.findIndex((tab) => tab.id === id);
return idx >= 0 ? root.querySelectorAll<HTMLButtonElement>(".mode-tab")[idx] ?? null : null;
};
const firstPaint = useRef(true);
useLayoutEffect(() => {
const tab = tabFor(active);
if (tab) moveModeIndicator(tab, !firstPaint.current);
firstPaint.current = false;
}, [active]);
useEffect(() => {
const snap = () => {
const tab = tabFor(activeRef.current);
if (tab) moveModeIndicator(tab, false);
};
window.addEventListener("resize", snap);
void document.fonts?.ready.then(snap);
return () => window.removeEventListener("resize", snap);
}, []);
return (
<nav
ref={rootRef}
className={`mode-tabs${visualActive || active ? "" : " no-active"}`}
aria-label="工作模式"
>
{/* clip-path:url(#id) 必须写在文档内 <style> 里,外部 CSS 文件解析不到这个 SVG id */}
<style>{`
.mode-tabs .mode-indicator-mask {
clip-path: url(#modeButtonMask);
-webkit-clip-path: url(#modeButtonMask);
}
`}</style>
<svg className="mode-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse">
<rect rx="22" ry="22" x="0" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="126" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="252" y="0" width="110" height="44" />
</clipPath>
</defs>
</svg>
<span className="mode-indicator-mask" aria-hidden="true">
<span ref={indicatorRef} className="mode-indicator" id="modeIndicator" />
</span>
{MODE_TABS.map((tab) => (
<button
key={tab.id}
className={`mode-tab${(visualActive ?? active) === tab.id ? " active" : ""}`}
type="button"
data-page={tab.id}
onClick={(event) => {
setVisualActive(tab.id);
moveModeIndicator(event.currentTarget, true);
navigate(tab.page);
}}
>{tab.label}</button>
))}
</nav>
);
}
const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
dashboard: "dashboard",
products: "products",
productDetail: "products",
productCreateUpload: "products",
models: "models",
projects: "projects",
projectWizard: "projects",
quickCreate: "projects",
pipeline: "projects",
assetFactory: "assetFactory",
imageOptimize: "assetFactory",
modelPhoto: "assetFactory",
modelPhotoDemoA: "assetFactory",
modelPhotoDemoB: "assetFactory",
platformCover: "assetFactory",
freeCreate: "freeCreate",
videoRemix: "projects",
videoReplace: "projects",
library: "library",
team: "team",
account: "account",
trash: "trash",
settings: "settings",
settingsNotify: "settings",
omniCreate: "omniCreate",
omniHistory: "omniHistory"
};
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, onOpenAdmin, onOpenAccount, accountOpen = false }: {
page: Page;
navigate: Navigate;
user: User;
team?: Team | null;
// 主账号(owner=超管)才看得到「团队」「消费」入口;子账号隐藏(PMC#3)。默认 true 兼容旧调用。
canManageBilling?: boolean;
products: Product[];
projects: Project[];
productTotal?: number;
projectTotal?: number;
// YYX#row22:未读生成任务总数 → 「图片生成」右侧数字胶囊
aiUnread?: number;
// 平台超管入口:仅 user.is_platform_admin 时显示,点了进 /admin 后台
onOpenAdmin?: () => void;
onOpenAccount?: (rect: DOMRect) => void;
accountOpen?: boolean;
}) {
const activeNav = PAGE_TO_NAV[page];
// 子账号:导航里去掉「团队」「消费」两项(命令面板、账户菜单同步过滤)。
const navItems = NAV.filter((item) => canManageBilling || (item.page !== "team" && item.page !== "account"));
const resourceNavItems = RESOURCE_NAV.filter((item) => navItems.some((nav) => nav.page === item.page));
const topModule = topModuleForPage(page);
const activeMajor: MajorNav | null =
activeNav === "account" ? "account"
: activeNav === "settings" ? "settings"
: topModule ? null
: resourceNavItems.some((item) => item.page === activeNav) ? "resource"
: null;
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
// YYX#row22:有未读生成任务才显示胶囊(0 不显示)
if (aiUnread && aiUnread > 0) badges["asset-factory"] = aiUnread;
// 收窄/展开导航:与设计稿 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]);
// 二级类目:跟当前大类走,同一帧收起,避免液态胶囊先飞到「展开后的位置」再追回来。
const [resourcePinned, setResourcePinned] = useState(true);
useEffect(() => {
if (activeMajor === "resource") setResourcePinned(true);
}, [activeMajor]);
const resourceOpen = activeMajor === "resource" && resourcePinned;
const resourceItemRef = useRef<HTMLButtonElement>(null);
const accountItemRef = useRef<HTMLButtonElement>(null);
const settingsItemRef = useRef<HTMLButtonElement>(null);
const submenuRef = useRef<HTMLDivElement>(null);
const prevMajorRef = useRef<MajorNav | null>(activeMajor);
const [liquid, setLiquid] = useState({ y: 0, hidden: true, pulse: false });
const skipPulse = useRef(true);
useLayoutEffect(() => {
const item = activeMajor === "resource" ? resourceItemRef.current
: activeMajor === "account" ? accountItemRef.current
: activeMajor === "settings" ? settingsItemRef.current
: null;
const leavingResource = prevMajorRef.current === "resource" && activeMajor !== "resource";
prevMajorRef.current = activeMajor;
const targetY = (el: HTMLElement, subtractSubmenu: boolean) => {
let top = el.offsetTop;
if (subtractSubmenu && submenuRef.current) {
const style = window.getComputedStyle(submenuRef.current);
const space = submenuRef.current.offsetHeight
+ (Number.parseFloat(style.marginTop) || 0)
+ (Number.parseFloat(style.marginBottom) || 0);
if (space > 1) top -= space;
}
return top - Math.max(0, (76 - el.offsetHeight) / 2);
};
if (!item) {
setLiquid((prev) => ({ ...prev, hidden: true, pulse: false }));
skipPulse.current = false;
return;
}
const animate = !skipPulse.current;
skipPulse.current = false;
setLiquid({
y: targetY(item, leavingResource),
hidden: false,
pulse: animate,
});
const onResize = () => {
const current = activeMajor === "resource" ? resourceItemRef.current
: activeMajor === "account" ? accountItemRef.current
: activeMajor === "settings" ? settingsItemRef.current
: null;
if (!current) return;
setLiquid((prev) => ({ ...prev, y: targetY(current, false), pulse: false }));
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [activeMajor, canManageBilling]);
useEffect(() => {
if (!liquid.pulse) return;
const timer = window.setTimeout(() => setLiquid((prev) => ({ ...prev, pulse: false })), 520);
return () => window.clearTimeout(timer);
}, [liquid.pulse]);
// 命令面板:Ctrl/Cmd K,以及顶栏搜索按钮
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((value) => !value);
}
};
const onOpen = () => setPaletteOpen(true);
window.addEventListener("keydown", onKey);
window.addEventListener(OPEN_PALETTE_EVENT, onOpen);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener(OPEN_PALETTE_EVENT, onOpen);
};
}, []);
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="/omni-create" aria-label="影擎全能创作" onClick={(event) => { event.preventDefault(); navigate("omniCreate"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></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="nav-panel">
<div className="nav-list">
<div
className={`liquid-indicator${liquid.hidden ? " is-hidden" : ""}${activeMajor && MAJOR_META[activeMajor].label.length >= 4 ? " wide-label" : ""}${liquid.pulse ? " pulse" : ""}`}
aria-hidden="true"
style={{ transform: `translateY(${liquid.y}px)` }}
>
<svg className="liquid-shape" viewBox="0 0 132 76" preserveAspectRatio="none">
<path fill="#101012" d="M0 11H103C120 11 130 22 130 38C130 54 120 65 103 65H0Z" />
</svg>
{activeMajor && (
<div className="indicator-content">
<span className="indicator-icon-slot"><LiquidIcon name={MAJOR_META[activeMajor].icon} /></span>
<span>{MAJOR_META[activeMajor].label}</span>
</div>
)}
</div>
<button
ref={resourceItemRef}
className={`nav-item${activeMajor === "resource" ? " active" : ""}`}
type="button"
aria-expanded={resourceOpen}
aria-controls="resourceSubmenu"
onClick={() => {
if (activeMajor !== "resource") {
setResourcePinned(true);
navigate("products");
return;
}
setResourcePinned((value) => !value);
}}
>
<span className="nav-inner">
<Boxes size={23} strokeWidth={1.8} />
<span className="nav-label">资源中心</span>
<ChevronDown className="nav-chevron" size={17} strokeWidth={1.8} />
</span>
</button>
<div
ref={submenuRef}
id="resourceSubmenu"
className={`nav-submenu${resourceOpen ? " expanded" : ""}`}
aria-label="资源中心二级导航"
aria-hidden={!resourceOpen}
>
{resourceNavItems.map((item) => (
<button
key={item.id}
type="button"
className={`nav-subitem${activeNav === item.page ? " active" : ""}`}
onClick={() => { setMobileNavOpen(false); navigate(item.page); }}
>
{item.label}
{badges[item.id] !== undefined && <span className="pill-mini">{badges[item.id]}</span>}
</button>
))}
</div>
{canManageBilling && (
<button
ref={accountItemRef}
className={`nav-item${activeMajor === "account" ? " active" : ""}`}
type="button"
onClick={() => { setMobileNavOpen(false); navigate("account"); }}
>
<span className="nav-inner">
<WalletCards size={23} strokeWidth={1.8} />
<span className="nav-label">账户中心</span>
</span>
</button>
)}
<button
ref={settingsItemRef}
className={`nav-item${activeMajor === "settings" ? " active" : ""}`}
type="button"
onClick={() => { setMobileNavOpen(false); navigate("settings"); }}
>
<span className="nav-inner">
<Settings size={23} strokeWidth={1.8} />
<span className="nav-label">系统设置</span>
</span>
</button>
</div>
{user.is_platform_admin && onOpenAdmin && (
<nav>
<a
href="/admin"
title="平台后台"
aria-label="平台后台"
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); onOpenAdmin(); }}
>
<IconKitSvg name="shield" />
<span>平台后台</span>
</a>
</nav>
)}
</div>
<button
className="org-switcher"
type="button"
aria-label="打开账户菜单"
aria-haspopup="menu"
aria-expanded={accountOpen}
onClick={(event) => onOpenAccount?.(event.currentTarget.getBoundingClientRect())}
>
<span className="org-badge">
{user.avatar_url ? <img src={user.avatar_url} alt="" /> : (team?.name || user.username || "A").slice(0, 1).toUpperCase()}
</span>
<span className="org-switcher-name">{team?.name || user.username}</span>
<ChevronRight size={16} strokeWidth={1.8} />
</button>
</aside>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} canManageBilling={canManageBilling} />
</>
);
}
export function Decorations() {
return <div className="grid-bg" />;
}
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 createPortal(
<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>,
document.body,
);
}
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>
);
}