- /admin/settings 弃用 JSON 编辑器改 antd Tabs 分组表单(site/woocommerce/mail/seo/security),未知键落「其他」JSON 兜底 - 登录安全:reCAPTCHA v2/v3 开关+密钥+阈值(siteverify fail closed),失败次数锁定;/admin 与 /my-account 登录接入 - 内容正文与产品详细描述换 TipTap v3:工具栏/Markdown 输入与导入/HTML 源码/图片上传/媒体库选图 - 根路由补 viewport meta(移动端根因);390px 实测 14 关键页 0 横向溢出 - 首页 Advance Tabs ≤767px 横滑、联系页内联 margin 块收缩、/shop 网格 4→3→2 列、移动菜单手风琴
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
/**
|
||
* # 路由 /admin 后台布局
|
||
* 左侧 Sider 菜单 + 顶部 Header(当前用户 / 退出)。
|
||
* 未登录或无后台权限:整页渲染登录页(不再展示后台骨架)。
|
||
*/
|
||
import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||
import { App, Avatar, Button, Card, ConfigProvider, Form, Input, Layout, Menu, Space, Tag } from "antd";
|
||
import { LockKeyhole } from "lucide-react";
|
||
import {
|
||
DatabaseBackup,
|
||
Files,
|
||
Inbox,
|
||
Image as ImageIcon,
|
||
Languages,
|
||
LayoutDashboard,
|
||
List,
|
||
LogOut,
|
||
Package,
|
||
Route as RouteIcon,
|
||
ScrollText,
|
||
Settings,
|
||
ShoppingCart,
|
||
SlidersHorizontal,
|
||
UserRound,
|
||
} from "lucide-react";
|
||
import { useState, useEffect, type ReactNode } from "react";
|
||
import { StyleProvider } from "@ant-design/cssinjs";
|
||
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||
import { getLoginSecurityPublic } from "~/server/security";
|
||
import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha";
|
||
|
||
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
|
||
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
|
||
{
|
||
label: "概览",
|
||
children: [{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" }],
|
||
},
|
||
{
|
||
label: "内容",
|
||
children: [
|
||
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "页面 / 文章" },
|
||
{ key: "/admin/menus/", icon: <List size={16} aria-hidden />, label: "菜单" },
|
||
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体库" },
|
||
],
|
||
},
|
||
{
|
||
label: "商务",
|
||
children: [
|
||
{ key: "/admin/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
|
||
{ key: "/admin/orders/", icon: <ShoppingCart size={16} aria-hidden />, label: "订单" },
|
||
{ key: "/admin/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
|
||
],
|
||
},
|
||
{
|
||
label: "系统",
|
||
children: [
|
||
{ key: "/admin/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
|
||
{ key: "/admin/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
|
||
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
|
||
{ key: "/admin/users/", icon: <UserRound size={16} aria-hidden />, label: "用户" },
|
||
{ key: "/admin/settings/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||
{ key: "/admin/backups/", icon: <DatabaseBackup size={16} aria-hidden />, label: "备份" },
|
||
{ key: "/admin/audit/", icon: <ScrollText size={16} aria-hidden />, label: "审计日志" },
|
||
],
|
||
},
|
||
];
|
||
|
||
/** # 类型 后台菜单项(扁平视图,用于路径高亮匹配) **/
|
||
const MENU_ITEMS = MENU_GROUPS.flatMap((g) => g.children);
|
||
|
||
/** # 类型 合法菜单路径(用于类型安全的导航) **/
|
||
type MenuKey = string;
|
||
|
||
/** # 类型 拥有后台权限的角色 **/
|
||
const ADMIN_ROLES = ["admin", "editor", "manager"];
|
||
|
||
export const Route = createFileRoute("/admin")({
|
||
loader: () => getCurrentUser(),
|
||
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 AdminLayout() {
|
||
const user = Route.useLoaderData();
|
||
const router = useRouter();
|
||
|
||
/** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */
|
||
async function handleLoginSuccess() {
|
||
await router.invalidate();
|
||
}
|
||
|
||
if (!user || !ADMIN_ROLES.includes(user.role)) {
|
||
return <AdminLoginPage noPermission={!!user} onOk={handleLoginSuccess} />;
|
||
}
|
||
|
||
return <AdminShell user={user} />;
|
||
}
|
||
|
||
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染) */
|
||
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
||
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("人机验证未完成,请完成勾选/等待验证脚本加载后重试");
|
||
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 : "登录失败,请稍后重试");
|
||
recaptcha.reset();
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||
{/* hashPriority=high:运行时生成的选择器不带哈希前缀,与烘焙 CSS(antd-baked.css)保持一致 */}
|
||
<StyleProvider hashPriority="high">
|
||
<App>
|
||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, #f5f8fb 0%, #e8eef5 100%)" }}>
|
||
<Card style={{ width: 380, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
|
||
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||
<LockKeyhole size={34} aria-hidden color="#1677ff" style={{ marginBottom: 10 }} />
|
||
<h2 style={{ margin: 0, fontSize: 20 }}>DusunIoT 后台</h2>
|
||
<p style={{ margin: "6px 0 0", color: "#8c8c8c", fontSize: 13 }}>
|
||
{props.noPermission ? "当前账号无后台权限,请更换管理员账号" : "请使用管理员账号登录"}
|
||
</p>
|
||
</div>
|
||
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
||
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: "请输入邮箱" }]}>
|
||
<Input type="email" placeholder="admin@example.com" autoComplete="email" />
|
||
</Form.Item>
|
||
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
||
<Input.Password placeholder="密码" autoComplete="current-password" />
|
||
</Form.Item>
|
||
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
||
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
||
{error ? (
|
||
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||
登录
|
||
</Button>
|
||
</Form>
|
||
</Card>
|
||
</div>
|
||
</App>
|
||
</StyleProvider>
|
||
</ConfigProvider>
|
||
);
|
||
}
|
||
|
||
/** # 函数 后台已授权布局外壳
|
||
* 顶部 Header 展示当前用户与退出按钮;左侧菜单按当前路径高亮;Outlet 渲染子页面。
|
||
* 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。
|
||
*/
|
||
function AdminShell({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||
const navigate = useNavigate();
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||
|
||
/** # 成员变量 当前应高亮的菜单 key:取最长匹配的菜单路径 **/
|
||
const selectedKey = MENU_ITEMS.map((m) => m.key)
|
||
.filter((k) => pathname === k || pathname.startsWith(k + "/"))
|
||
.sort((a, b) => b.length - a.length)[0] ?? "/admin/";
|
||
|
||
return (
|
||
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||
<StyleProvider hashPriority="high">
|
||
<App>
|
||
<Layout style={{ minHeight: "100vh" }}>
|
||
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200}>
|
||
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 16, letterSpacing: 1 }}>
|
||
{collapsed ? "D" : "DusunIoT 后台"}
|
||
</div>
|
||
<Menu
|
||
theme="dark"
|
||
mode="inline"
|
||
items={MENU_GROUPS.map((g) => ({
|
||
type: "group" as const,
|
||
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{g.label}</span>,
|
||
children: g.children.map((m) => ({ key: m.key, icon: m.icon, label: m.label })),
|
||
}))}
|
||
selectedKeys={[selectedKey]}
|
||
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
||
/>
|
||
</Layout.Sider>
|
||
<Layout>
|
||
<Layout.Header style={{ background: "#fff", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid #f0f0f0" }}>
|
||
<UserBadge user={user} />
|
||
</Layout.Header>
|
||
<Layout.Content style={{ margin: 16 }}>
|
||
<Outlet />
|
||
</Layout.Content>
|
||
</Layout>
|
||
</Layout>
|
||
</App>
|
||
</StyleProvider>
|
||
</ConfigProvider>
|
||
);
|
||
}
|
||
|
||
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message) */
|
||
function UserBadge({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||
const navigate = useNavigate();
|
||
const router = useRouter();
|
||
const { message } = App.useApp();
|
||
|
||
/** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */
|
||
async function handleLogout() {
|
||
await logoutFn();
|
||
message.success("已退出登录");
|
||
await router.invalidate();
|
||
void navigate({ to: "/admin/" });
|
||
}
|
||
|
||
return (
|
||
<Space size={12}>
|
||
<Avatar size="small" icon={<UserRound size={14} aria-hidden />} />
|
||
<span>{user.displayName || user.email}</span>
|
||
<Tag color={user.role === "admin" ? "blue" : "default"}>{user.role}</Tag>
|
||
<Button size="small" icon={<LogOut size={14} aria-label="退出登录" />} onClick={() => void handleLogout()}>
|
||
退出
|
||
</Button>
|
||
</Space>
|
||
);
|
||
}
|