feat(admin): 管理后台 - 统一桌面端信息架构与视觉规范
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { ConfigProvider, Tooltip } from "antd";
|
||||
import { ConfigProvider, Dropdown, Tooltip } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
BellRing,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
CloudUpload,
|
||||
Coins,
|
||||
Database,
|
||||
FileClock,
|
||||
HardDrive,
|
||||
Home,
|
||||
@@ -14,26 +17,27 @@ import {
|
||||
Layers3,
|
||||
Mail,
|
||||
MessageSquareText,
|
||||
Moon,
|
||||
Paintbrush,
|
||||
PlugZap,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RadioTower,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Sun,
|
||||
TicketCheck,
|
||||
ToggleLeft,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
import { WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WORKSPACE_SIDEBAR_STORAGE_KEY } from "@/components/layout/workspace-sidebar-state";
|
||||
import { publishWorkspaceSidebarCollapsed, readWorkspaceSidebarCollapsed, subscribeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state";
|
||||
import { getAdminAntThemeConfig } from "@/lib/app-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import "@/styles/admin-ui.css";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
@@ -58,7 +62,6 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
|
||||
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
|
||||
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
|
||||
{ path: "/admin/resources", label: "存储资源", description: "资源列表、容量与预览", icon: <Database className="size-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -86,55 +89,72 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
},
|
||||
];
|
||||
|
||||
function isAdminNavigationPath(pathname: string, navigationPath: string) {
|
||||
if (navigationPath === "/admin") {
|
||||
return pathname === navigationPath;
|
||||
}
|
||||
return pathname === navigationPath || pathname.startsWith(`${navigationPath}/`);
|
||||
}
|
||||
|
||||
export function AdminShell() {
|
||||
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(WORKSPACE_SIDEBAR_STORAGE_KEY) === "1");
|
||||
const [collapsed, setCollapsed] = useState(readWorkspaceSidebarCollapsed);
|
||||
const dark = useThemeStore((state) => state.theme === "dark");
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add("admin-overlays");
|
||||
return () => document.body.classList.remove("admin-overlays");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeWorkspaceSidebarCollapsed(setCollapsed);
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((current) => {
|
||||
const next = !current;
|
||||
window.localStorage.setItem(WORKSPACE_SIDEBAR_STORAGE_KEY, next ? "1" : "0");
|
||||
return next;
|
||||
});
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
publishWorkspaceSidebarCollapsed(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={getAdminAntThemeConfig(dark)}>
|
||||
<main className="admin-shell app-user-workspace flex h-full min-h-0 overflow-hidden text-foreground">
|
||||
<aside className={cn("app-workspace-sidebar admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed && "is-collapsed")}>
|
||||
<div className={cn("admin-sidebar-header flex shrink-0 items-center", collapsed ? "justify-center" : "gap-2 px-3")}>
|
||||
{!collapsed ? (
|
||||
<Link to="/" className="app-workspace-brand-link flex min-w-0 flex-1 items-center gap-2" title="影策">
|
||||
<span className="admin-sidebar-brand-mark grid shrink-0 place-items-center bg-foreground text-background">
|
||||
<InfinityIcon className="size-4" />
|
||||
</span>
|
||||
<span className="admin-sidebar-brand-copy min-w-0">
|
||||
<span className="block truncate font-semibold">影策</span>
|
||||
<span className="block truncate">管理后台</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "展开侧栏" : "折叠侧栏"} placement="right">
|
||||
<button type="button" className="app-workspace-icon-button admin-sidebar-toggle shrink-0" onClick={toggleCollapsed} aria-label={collapsed ? "展开侧栏" : "折叠侧栏"}>
|
||||
{collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
|
||||
</button>
|
||||
<div className="admin-sidebar-identity shrink-0">
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "查看更新日志" : undefined} placement="right" rootClassName="app-workspace-sidebar-tooltip">
|
||||
<AppChangelogButton
|
||||
className={cn("admin-sidebar-brand-button", collapsed && "is-collapsed")}
|
||||
icon={
|
||||
<span className="admin-sidebar-brand-mark grid shrink-0 place-items-center bg-foreground text-background">
|
||||
<InfinityIcon className="size-4" />
|
||||
</span>
|
||||
}
|
||||
label="影策"
|
||||
showLabel={!collapsed}
|
||||
showVersion={!collapsed}
|
||||
labelClassName="admin-sidebar-brand-title"
|
||||
versionClassName="admin-sidebar-brand-version"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<AdminNavigation collapsed={collapsed} />
|
||||
<div className="admin-sidebar-footer shrink-0">
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "更新日志" : undefined} placement="right">
|
||||
<AppChangelogButton
|
||||
className={cn("flex h-8 w-full items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-surface-hover hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}
|
||||
showVersion={!collapsed}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "返回创作台" : undefined} placement="right">
|
||||
<NavLink to="/canvas" className={cn("flex h-8 items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-surface-hover hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}>
|
||||
<Home className="size-3.5" />
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "返回创作台" : undefined} placement="right" rootClassName="app-workspace-sidebar-tooltip">
|
||||
<NavLink
|
||||
to="/home"
|
||||
aria-label={collapsed ? "返回创作台" : undefined}
|
||||
className={cn("app-workspace-nav-link group flex h-8 items-center text-foreground/62 transition-colors hover:bg-surface-hover hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2.5 px-2.5")}
|
||||
>
|
||||
<Home className="size-4" strokeWidth={1.6} />
|
||||
{!collapsed ? <span>返回创作台</span> : null}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</aside>
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "展开侧栏" : "收起侧栏"} placement="right" rootClassName="app-workspace-sidebar-tooltip">
|
||||
<button type="button" className={cn("admin-sidebar-edge-toggle hidden lg:grid", collapsed && "is-collapsed")} onClick={toggleCollapsed} aria-label={collapsed ? "展开侧栏" : "收起侧栏"} aria-expanded={!collapsed}>
|
||||
{collapsed ? <ChevronRight className="size-3.5" aria-hidden="true" /> : <ChevronLeft className="size-3.5" aria-hidden="true" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<MobileAdminNavigation />
|
||||
<Outlet />
|
||||
@@ -145,6 +165,11 @@ export function AdminShell() {
|
||||
}
|
||||
|
||||
export function AdminPageFrame({ title, description, actions, back, scroll = false, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; scroll?: boolean; children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const currentSection = adminNavigation.find((section) => section.items.some((item) => isAdminNavigationPath(location.pathname, item.path)));
|
||||
const sectionLabel = currentSection?.label ?? "管理后台";
|
||||
const sectionPath = currentSection?.items[0]?.path ?? "/admin";
|
||||
|
||||
return (
|
||||
<WorkspacePage scroll={scroll} fluid className={cn("admin-page-root", scroll && "admin-page-root-scrollable")}>
|
||||
<div className={cn("admin-page-frame", scroll && "admin-page-frame-scrollable")}>
|
||||
@@ -158,11 +183,22 @@ export function AdminPageFrame({ title, description, actions, back, scroll = fal
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div className="admin-page-title-block min-w-0">
|
||||
<h1 className="admin-page-title truncate font-semibold">{title}</h1>
|
||||
<nav className="admin-page-location" aria-label="当前位置">
|
||||
<Link to={sectionPath} className="admin-page-location-section">
|
||||
{sectionLabel}
|
||||
</Link>
|
||||
<span className="admin-page-location-separator" aria-hidden="true">
|
||||
/
|
||||
</span>
|
||||
<h1 className="admin-page-title truncate font-semibold">{title}</h1>
|
||||
</nav>
|
||||
{description ? <p className="admin-page-description">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
<div className="admin-page-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
<AdminThemeButton />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
@@ -170,26 +206,61 @@ export function AdminPageFrame({ title, description, actions, back, scroll = fal
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAdminNavigation() {
|
||||
const features = useUserStore((state) => state.features);
|
||||
const visibleItems = adminNavigation.flatMap((group) => group.items).filter((item) => !item.requireFeature || features[item.requireFeature]);
|
||||
function AdminThemeButton() {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const dark = theme === "dark";
|
||||
|
||||
return (
|
||||
<nav className="app-workspace-navigation hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 px-3 py-2 lg:hidden" aria-label="管理后台分区">
|
||||
{visibleItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === "/admin"}
|
||||
className={({ isActive }) =>
|
||||
cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-surface-hover hover:text-foreground")
|
||||
}
|
||||
>
|
||||
<Tooltip title={dark ? "切换到浅色主题" : "切换到深色主题"} placement="bottom">
|
||||
<button type="button" className="app-workspace-icon-button admin-page-theme-toggle size-9 shrink-0" onClick={() => setTheme(dark ? "light" : "dark")} aria-label={dark ? "切换到浅色主题" : "切换到深色主题"}>
|
||||
{dark ? <Sun className="size-4" aria-hidden="true" /> : <Moon className="size-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAdminNavigation() {
|
||||
const features = useUserStore((state) => state.features);
|
||||
const location = useLocation();
|
||||
const visibleGroups = adminNavigation.map((group) => ({ ...group, items: group.items.filter((item) => !item.requireFeature || features[item.requireFeature]) })).filter((group) => group.items.length > 0);
|
||||
const visibleItems = visibleGroups.flatMap((group) => group.items);
|
||||
const currentItem = visibleItems.find((item) => item.path === location.pathname) || visibleItems[0];
|
||||
const menuItems: MenuProps["items"] = visibleGroups.map((group) => ({
|
||||
type: "group",
|
||||
key: `group-${group.label}`,
|
||||
label: group.label,
|
||||
children: group.items.map((item) => ({
|
||||
key: item.path,
|
||||
label: (
|
||||
<Link to={item.path} className="admin-mobile-navigation-menu-link">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<AppChangelogButton className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground [&_svg]:size-4" />
|
||||
</Link>
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
return (
|
||||
<nav className="admin-mobile-navigation flex shrink-0 items-center justify-between gap-2 border-b lg:hidden" aria-label="管理后台导航">
|
||||
<Dropdown menu={{ items: menuItems, selectable: true, selectedKeys: currentItem ? [currentItem.path] : [], className: "admin-mobile-navigation-menu" }} trigger={["click"]} placement="bottomLeft">
|
||||
<button type="button" className="admin-mobile-navigation-trigger" aria-label={`打开管理后台导航,当前页面${currentItem?.label || "未知"}`}>
|
||||
<span className="admin-mobile-navigation-current-icon">{currentItem?.icon}</span>
|
||||
<span className="min-w-0 text-left">
|
||||
<span className="admin-mobile-navigation-eyebrow">管理后台</span>
|
||||
<span className="admin-mobile-navigation-current-label">{currentItem?.label || "选择页面"}</span>
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip title="返回创作台">
|
||||
<Link to="/home" className="admin-mobile-navigation-action" aria-label="返回创作台">
|
||||
<Home className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<AppChangelogButton className="admin-mobile-navigation-action [&_svg]:size-4" />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -212,12 +283,13 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) {
|
||||
) : (
|
||||
<div className="admin-nav-collapsed-separator" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
<div className={cn("admin-nav-group-items space-y-0.5", collapsed && "is-collapsed")}>
|
||||
{visibleItems.map((item) => (
|
||||
<Tooltip key={item.path} mouseEnterDelay={0.1} title={collapsed ? item.label : undefined} placement="right">
|
||||
<Tooltip key={item.path} mouseEnterDelay={0.1} title={collapsed ? item.label : undefined} placement="right" rootClassName="app-workspace-sidebar-tooltip">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={item.path === "/admin"}
|
||||
aria-label={collapsed ? item.label : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"app-workspace-nav-link flex h-8 items-center rounded-md text-[var(--fs-body)] transition-colors",
|
||||
|
||||
Reference in New Issue
Block a user