feat(admin): 管理后台 - 统一桌面端信息架构与视觉规范

This commit is contained in:
HANG LU
2026-08-30 05:05:46 +08:00
parent 08937417ec
commit 90da7298b6
32 changed files with 17070 additions and 1860 deletions

View File

@@ -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",