329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
/**
|
||
* # 路由 /admin 后台布局
|
||
* 左侧 Sider 菜单(按角色权限过滤)+ 顶部 Header(语言选择 / 主题切换 / 用户头像下拉)。
|
||
* 未登录或无后台权限:整页渲染登录页。
|
||
* v0.16.0:后台 i18n(默认英文)、浅/深主题、个人资料入口、菜单权限。
|
||
*/
|
||
import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||
import { App, Avatar, Button, Card, Dropdown, Form, Input, Layout, Menu, Select, Space, Tooltip } from "antd";
|
||
import { LockKeyhole, LogOut, Moon, Settings, Sun, UserRound, Languages } from "lucide-react";
|
||
import {
|
||
DatabaseBackup,
|
||
Files,
|
||
Inbox,
|
||
Image as ImageIcon,
|
||
Languages as LanguagesIcon,
|
||
LayoutDashboard,
|
||
List,
|
||
Package,
|
||
Route as RouteIcon,
|
||
ScrollText,
|
||
ShoppingCart,
|
||
ShieldCheck,
|
||
SlidersHorizontal,
|
||
UserRound as UserRoundIcon,
|
||
} from "lucide-react";
|
||
import { useEffect, useState, type ReactNode } from "react";
|
||
import { loginFn, logoutFn } from "~/server/auth";
|
||
import { getAdminLayoutData } from "~/server/permissions";
|
||
import { getLoginSecurityPublic } from "~/server/security";
|
||
import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha";
|
||
import { AdminLocaleProvider, useAdminI18n, useAdminT, type AdminLocaleDef } from "~/admin-i18n";
|
||
import { ADMIN_LOCALES } from "~/admin-i18n/shared";
|
||
import { AdminThemeProvider, useAdminTheme } from "~/admin-theme";
|
||
import { ADMIN_MENU_GROUPS } from "~/admin-menus";
|
||
|
||
/** # 成员变量 菜单 key → 图标(菜单定义在 admin-menus.ts,图标在此补充) **/
|
||
const MENU_ICONS: Record<string, ReactNode> = {
|
||
"/admin/": <LayoutDashboard size={16} aria-hidden />,
|
||
"/admin/contents/": <Files size={16} aria-hidden />,
|
||
"/admin/menus/": <List size={16} aria-hidden />,
|
||
"/admin/media/": <ImageIcon size={16} aria-hidden />,
|
||
"/admin/products/": <Package size={16} aria-hidden />,
|
||
"/admin/orders/": <ShoppingCart size={16} aria-hidden />,
|
||
"/admin/inquiries/": <Inbox size={16} aria-hidden />,
|
||
"/admin/filters/": <SlidersHorizontal size={16} aria-hidden />,
|
||
"/admin/languages/": <LanguagesIcon size={16} aria-hidden />,
|
||
"/admin/seo/": <RouteIcon size={16} aria-hidden />,
|
||
"/admin/users/": <UserRoundIcon size={16} aria-hidden />,
|
||
"/admin/permissions/": <ShieldCheck size={16} aria-hidden />,
|
||
"/admin/settings/": <Settings size={16} aria-hidden />,
|
||
"/admin/backups/": <DatabaseBackup size={16} aria-hidden />,
|
||
"/admin/audit/": <ScrollText size={16} aria-hidden />,
|
||
};
|
||
|
||
/** # 类型 拥有后台权限的角色 **/
|
||
const ADMIN_ROLES = ["admin", "editor", "manager"];
|
||
|
||
/** # 类型 loader 返回(用户 + 该角色授权菜单) **/
|
||
type AdminLoaderData = {
|
||
user: { displayName: string | null; email: string; role: string; avatar: string; locale: string; theme: string } | null;
|
||
/** # 成员变量 当前角色可见的菜单 key(未登录为空) **/
|
||
menus: string[];
|
||
};
|
||
|
||
export const Route = createFileRoute("/admin")({
|
||
loader: async (): Promise<AdminLoaderData> => {
|
||
return await getAdminLayoutData();
|
||
},
|
||
component: AdminLayout,
|
||
// AntD 烘焙静态样式(scripts/gen-antd-css.tsx 生成到 public/):
|
||
// 用 <link> 而非 JS import —— dev 下 vite 会把 import 的 CSS 注入成 <style> 标签,
|
||
// 且 dev 预载全部路由模块会把它泄漏到前台每个页面(800KB style 标签导致 F12 卡死)。
|
||
// <link> 在 head 中渲染阻塞加载,SSR 首屏即有完整 AntD 样式,消除无样式闪烁。
|
||
head: () => ({
|
||
links: [{ rel: "stylesheet", href: "/antd-baked.css" }],
|
||
}),
|
||
});
|
||
|
||
/** # 组件 主题切换按钮(月亮/太阳图标,非开关;浅色显示月亮=点切深色,深色显示太阳=点切浅色) */
|
||
function ThemeToggleButton(props: { size?: "small" | "middle" }) {
|
||
const t = useAdminT();
|
||
const { theme, setTheme } = useAdminTheme();
|
||
const dark = theme === "dark";
|
||
return (
|
||
<Tooltip title={t("主题切换")}>
|
||
<Button
|
||
type="text"
|
||
size={props.size}
|
||
onClick={() => setTheme(dark ? "light" : "dark")}
|
||
icon={dark ? <Sun size={17} aria-hidden /> : <Moon size={17} aria-hidden />}
|
||
aria-label={t("主题切换")}
|
||
/>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
/** # 函数 后台布局组件
|
||
* 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。
|
||
* 外层 AdminLocaleProvider / AdminThemeProvider 为纯客户端偏好(localStorage)。
|
||
*/
|
||
function AdminLayout() {
|
||
const data = Route.useLoaderData();
|
||
const router = useRouter();
|
||
|
||
/** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */
|
||
async function handleLoginSuccess() {
|
||
await router.invalidate();
|
||
}
|
||
|
||
return (
|
||
<AdminLocaleProvider>
|
||
<AdminThemeProvider>
|
||
{!data.user || !ADMIN_ROLES.includes(data.user.role) ? (
|
||
<AdminLoginPage noPermission={!!data.user} onOk={handleLoginSuccess} />
|
||
) : (
|
||
<AdminShell user={data.user} allowedMenus={data.menus} />
|
||
)}
|
||
</AdminThemeProvider>
|
||
</AdminLocaleProvider>
|
||
);
|
||
}
|
||
|
||
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染;i18n + 暗色自适应) */
|
||
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
||
const t = useAdminT();
|
||
const { locale, setLocale } = useAdminI18n();
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
/** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/
|
||
const [secCfg, setSecCfg] = useState<LoginSecurityPublic | null>(null);
|
||
const recaptcha = useRecaptcha(secCfg);
|
||
|
||
// 登录页挂载后拉取登录安全公开配置(未启用时返回 enabled=false,不注入脚本)
|
||
useEffect(() => {
|
||
void getLoginSecurityPublic()
|
||
.then(setSecCfg)
|
||
.catch(() => setSecCfg({ enabled: false, version: "v2", siteKey: "" }));
|
||
}, []);
|
||
|
||
/** # 函数 登录提交:调用 loginFn(附 reCAPTCHA 令牌),成功回调 onOk,失败展示错误并重置复选框 */
|
||
async function handleFinish(values: { email: string; password: string }) {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const token = await recaptcha.getToken();
|
||
if (secCfg?.enabled && token === null) {
|
||
setError(t("人机验证未完成,请完成勾选/等待验证脚本加载后重试"));
|
||
recaptcha.reset();
|
||
return;
|
||
}
|
||
const res = await loginFn({ data: { email: values.email, password: values.password, recaptchaToken: token ?? undefined } });
|
||
if (!res.ok) {
|
||
setError(res.error);
|
||
recaptcha.reset();
|
||
} else await props.onOk();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : t("登录失败,请稍后重试"));
|
||
recaptcha.reset();
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--ant-color-bg-layout)", position: "relative" }}>
|
||
{/* 语言 / 主题偏好(登录页同样可用) */}
|
||
<Space size={10} style={{ position: "absolute", top: 20, right: 28 }}>
|
||
<Select<AdminLocaleDef["code"]>
|
||
value={locale}
|
||
onChange={setLocale}
|
||
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
|
||
style={{ width: 150 }}
|
||
aria-label={t("语言")}
|
||
/>
|
||
<ThemeToggleButton />
|
||
</Space>
|
||
<Card style={{ width: 440, boxShadow: "0 12px 40px rgba(16, 42, 76, 0.14)", borderRadius: 12 }}>
|
||
<div style={{ textAlign: "center", marginBottom: 32 }}>
|
||
<LockKeyhole size={44} aria-hidden color="#1677ff" style={{ marginBottom: 14 }} />
|
||
<h2 style={{ margin: 0, fontSize: 26 }}>{t("DusunIoT 后台")}</h2>
|
||
<p style={{ margin: "10px 0 0", color: "var(--ant-color-text-tertiary)", fontSize: 15 }}>
|
||
{props.noPermission ? t("当前账号无后台权限,请更换管理员账号") : t("请使用管理员账号登录")}
|
||
</p>
|
||
</div>
|
||
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
||
<Form.Item name="email" label={t("邮箱")} rules={[{ required: true, message: t("请输入邮箱") }]}>
|
||
<Input type="email" size="large" placeholder="admin@example.com" autoComplete="email" />
|
||
</Form.Item>
|
||
<Form.Item name="password" label={t("密码")} rules={[{ required: true, message: t("请输入密码") }]}>
|
||
<Input.Password size="large" placeholder={t("密码")} autoComplete="current-password" />
|
||
</Form.Item>
|
||
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
||
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
||
{error ? (
|
||
<div role="alert" style={{ color: "var(--ant-color-error)", background: "var(--ant-color-error-bg)", border: "1px solid var(--ant-color-error-border)", borderRadius: 8, padding: "10px 14px", marginBottom: 16, fontSize: 14 }}>
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||
{t("登录")}
|
||
</Button>
|
||
</Form>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** # 函数 后台已授权布局外壳
|
||
* 顶部 Header 展示语言 / 主题 / 用户区;左侧菜单按当前路径高亮并按权限过滤;Outlet 渲染子页面。
|
||
* 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。
|
||
*/
|
||
function AdminShell(props: { user: NonNullable<AdminLoaderData["user"]>; allowedMenus: string[] }) {
|
||
const t = useAdminT();
|
||
const navigate = useNavigate();
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||
|
||
/** # 成员变量 按权限过滤后的菜单分组(个人设置对所有后台角色可见) **/
|
||
const allowed = new Set(props.allowedMenus);
|
||
allowed.add("/admin/profile");
|
||
const groups = ADMIN_MENU_GROUPS.map((g) => ({
|
||
type: "group" as const,
|
||
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{t(g.label)}</span>,
|
||
children: g.children
|
||
.filter((m) => allowed.has(m.key))
|
||
.map((m) => ({ key: m.key, icon: MENU_ICONS[m.key], label: t(m.label) })),
|
||
})).filter((g) => g.children.length > 0);
|
||
|
||
/** # 成员变量 当前应高亮的菜单 key:取最长匹配的菜单路径 **/
|
||
const selectedKey = [...allowed]
|
||
.filter((k) => pathname === k || pathname.startsWith(k))
|
||
.sort((a, b) => b.length - a.length)[0] ?? "/admin/";
|
||
|
||
return (
|
||
<Layout style={{ minHeight: "100vh" }} className="dusun">
|
||
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200} theme="dark">
|
||
<div style={{ height: 60, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 18, letterSpacing: 1 }}>
|
||
{collapsed ? "D" : t("DusunIoT 后台")}
|
||
</div>
|
||
<Menu
|
||
theme="dark"
|
||
mode="inline"
|
||
items={groups}
|
||
selectedKeys={[selectedKey]}
|
||
onClick={({ key }) => void navigate({ to: key })}
|
||
/>
|
||
</Layout.Sider>
|
||
<Layout>
|
||
<Layout.Header style={{ background: "var(--ant-color-bg-container)", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 28, borderBottom: "1px solid var(--ant-color-split)", height: 60 }}>
|
||
<UserBadge user={props.user} />
|
||
</Layout.Header>
|
||
<Layout.Content style={{ margin: "20px 24px 28px" }}>
|
||
<Outlet />
|
||
</Layout.Content>
|
||
</Layout>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message)
|
||
* 从左到右:语言选择 → 主题切换 → 用户头像下拉(个人设置 / 退出登录)。
|
||
*/
|
||
function UserBadge(props: { user: NonNullable<AdminLoaderData["user"]> }) {
|
||
const t = useAdminT();
|
||
const { locale, setLocale } = useAdminI18n();
|
||
const navigate = useNavigate();
|
||
const router = useRouter();
|
||
const { message } = App.useApp();
|
||
|
||
/** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */
|
||
async function handleLogout() {
|
||
await logoutFn();
|
||
message.success(t("已退出登录"));
|
||
await router.invalidate();
|
||
void navigate({ to: "/admin/" });
|
||
}
|
||
|
||
return (
|
||
<Space size={16}>
|
||
<Space size={6}>
|
||
<Languages size={16} aria-hidden style={{ color: "var(--ant-color-text-tertiary)" }} />
|
||
<Select
|
||
variant="borderless"
|
||
value={locale}
|
||
onChange={setLocale}
|
||
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
|
||
style={{ minWidth: 130 }}
|
||
aria-label={t("语言")}
|
||
/>
|
||
</Space>
|
||
<ThemeToggleButton />
|
||
<Dropdown
|
||
menu={{
|
||
items: [
|
||
{
|
||
key: "profile",
|
||
icon: <UserRound size={14} aria-hidden />,
|
||
label: (
|
||
<div style={{ minWidth: 160 }}>
|
||
<div style={{ fontWeight: 600 }}>{props.user.displayName || props.user.email}</div>
|
||
<div style={{ fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>{props.user.email}</div>
|
||
</div>
|
||
),
|
||
disabled: true,
|
||
},
|
||
{ type: "divider" as const },
|
||
{ key: "settings", icon: <Settings size={14} aria-hidden />, label: t("个人设置") },
|
||
{ key: "logout", icon: <LogOut size={14} aria-hidden />, label: t("退出登录"), danger: true },
|
||
],
|
||
onClick: ({ key }) => {
|
||
if (key === "settings") void navigate({ to: "/admin/profile/" });
|
||
else if (key === "logout") void handleLogout();
|
||
},
|
||
}}
|
||
placement="bottomRight"
|
||
>
|
||
<Space size={10} style={{ cursor: "pointer" }} data-testid="user-menu">
|
||
{props.user.avatar ? (
|
||
<Avatar size={34} src={props.user.avatar} />
|
||
) : (
|
||
<Avatar size={34} icon={<UserRound size={17} aria-hidden />} />
|
||
)}
|
||
<span style={{ fontSize: 15 }}>{props.user.displayName || props.user.email}</span>
|
||
</Space>
|
||
</Dropdown>
|
||
</Space>
|
||
);
|
||
}
|