Files
dusuniot/src/routes/admin.tsx
T

329 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 /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>
);
}