v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON

This commit is contained in:
bigcode
2026-11-05 01:46:00 +08:00
parent c3df5d9db1
commit a018019504
47 changed files with 3008 additions and 830 deletions
+211 -143
View File
@@ -1,81 +1,71 @@
/**
* # 路由 /admin 后台布局
* 左侧 Sider 菜单 + 顶部 Header(当前用户 / 退出)。
* 未登录或无后台权限:整页渲染登录页(不再展示后台骨架)。
* 左侧 Sider 菜单(按角色权限过滤)+ 顶部 Header(语言选择 / 主题切换 / 用户头像下拉)。
* 未登录或无后台权限:整页渲染登录页。
* v0.16.0:后台 i18n(默认英文)、浅/深主题、个人资料入口、菜单权限。
*/
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 { App, Avatar, Button, Card, Dropdown, Form, Input, Layout, Menu, Select, Space, Switch, Tag } from "antd";
import { LockKeyhole, LogOut, Moon, Settings, Sun, UserRound, Languages } from "lucide-react";
import {
DatabaseBackup,
Files,
Inbox,
Image as ImageIcon,
Languages,
Languages as LanguagesIcon,
LayoutDashboard,
List,
LogOut,
Package,
Route as RouteIcon,
ScrollText,
Settings,
ShoppingCart,
ShieldCheck,
SlidersHorizontal,
UserRound,
UserRound as UserRoundIcon,
} from "lucide-react";
import { useState, useEffect, type ReactNode } from "react";
import { StyleProvider } from "@ant-design/cssinjs";
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
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 即目标路由路径) **/
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;
/** # 成员变量 菜单 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: () => getCurrentUser(),
loader: async (): Promise<AdminLoaderData> => {
return await getAdminLayoutData();
},
component: AdminLayout,
// AntD 烘焙静态样式(scripts/gen-antd-css.tsx 生成到 public/):
// 用 <link> 而非 JS import —— dev 下 vite 会把 import 的 CSS 注入成 <style> 标签,
@@ -88,9 +78,10 @@ export const Route = createFileRoute("/admin")({
/** # 函数 后台布局组件
* 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。
* 外层 AdminLocaleProvider / AdminThemeProvider 为纯客户端偏好(localStorage)。
*/
function AdminLayout() {
const user = Route.useLoaderData();
const data = Route.useLoaderData();
const router = useRouter();
/** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */
@@ -98,15 +89,24 @@ function AdminLayout() {
await router.invalidate();
}
if (!user || !ADMIN_ROLES.includes(user.role)) {
return <AdminLoginPage noPermission={!!user} onOk={handleLoginSuccess} />;
}
return <AdminShell user={user} />;
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 按系统设置「登录安全」开关渲染) */
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染;i18n + 暗色自适应) */
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
const t = useAdminT();
const { locale, setLocale } = useAdminI18n();
const { theme, setTheme } = useAdminTheme();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
/** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/
@@ -127,7 +127,7 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
try {
const token = await recaptcha.getToken();
if (secCfg?.enabled && token === null) {
setError("人机验证未完成,请完成勾选/等待验证脚本加载后重试");
setError(t("人机验证未完成,请完成勾选/等待验证脚本加载后重试"));
recaptcha.reset();
return;
}
@@ -137,7 +137,7 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
recaptcha.reset();
} else await props.onOk();
} catch (e) {
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
setError(e instanceof Error ? e.message : t("登录失败,请稍后重试"));
recaptcha.reset();
} finally {
setLoading(false);
@@ -145,97 +145,116 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
}
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 style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--ant-color-bg-layout)", position: "relative" }}>
{/* 语言 / 主题偏好(登录页同样可用) */}
<Space size={8} style={{ position: "absolute", top: 16, right: 24 }}>
<Select<AdminLocaleDef["code"]>
size="small"
value={locale}
onChange={setLocale}
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
style={{ width: 130 }}
aria-label={t("语言")}
/>
<Switch
size="small"
checked={theme === "dark"}
checkedChildren={<Moon size={12} aria-hidden />}
unCheckedChildren={<Sun size={12} aria-hidden />}
onChange={(v) => setTheme(v ? "dark" : "light")}
aria-label={t("主题切换")}
/>
</Space>
<Card style={{ width: 420, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
<div style={{ textAlign: "center", marginBottom: 28 }}>
<LockKeyhole size={38} aria-hidden color="#1677ff" style={{ marginBottom: 12 }} />
<h2 style={{ margin: 0, fontSize: 24 }}>{t("DusunIoT 后台")}</h2>
<p style={{ margin: "8px 0 0", color: "var(--ant-color-text-tertiary)", fontSize: 14 }}>
{props.noPermission ? t("当前账号无后台权限,请更换管理员账号") : t("请使用管理员账号登录")}
</p>
</div>
</App>
</StyleProvider>
</ConfigProvider>
<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" placeholder="admin@example.com" autoComplete="email" />
</Form.Item>
<Form.Item name="password" label={t("密码")} rules={[{ required: true, message: t("请输入密码") }]}>
<Input.Password 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: 6, padding: "8px 12px", marginBottom: 16, fontSize: 14 }}>
{error}
</div>
) : null}
<Button type="primary" htmlType="submit" block loading={loading}>
{t("登录")}
</Button>
</Form>
</Card>
</div>
);
}
/** # 函数 后台已授权布局外壳
* 顶部 Header 展示当前用户与退出按钮;左侧菜单按当前路径高亮;Outlet 渲染子页面。
* 顶部 Header 展示语言 / 主题 / 用户区;左侧菜单按当前路径高亮并按权限过滤;Outlet 渲染子页面。
* 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。
*/
function AdminShell({ user }: { user: { displayName: string | null; email: string; role: string } }) {
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 = MENU_ITEMS.map((m) => m.key)
.filter((k) => pathname === k || pathname.startsWith(k + "/"))
const selectedKey = [...allowed]
.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>
<Layout style={{ minHeight: "100vh" }} className="dusun">
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200} theme="dark">
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 17, 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: 24, borderBottom: "1px solid var(--ant-color-split)", height: 56 }}>
<UserBadge user={props.user} />
</Layout.Header>
<Layout.Content style={{ margin: 16 }}>
<Outlet />
</Layout.Content>
</Layout>
</Layout>
);
}
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message) */
function UserBadge({ user }: { user: { displayName: string | null; email: string; role: string } }) {
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message)
* 从左到右:语言选择 → 主题切换 → 用户头像下拉(个人设置 / 退出登录)。
*/
function UserBadge(props: { user: NonNullable<AdminLoaderData["user"]> }) {
const t = useAdminT();
const { locale, setLocale } = useAdminI18n();
const { theme, setTheme } = useAdminTheme();
const navigate = useNavigate();
const router = useRouter();
const { message } = App.useApp();
@@ -243,19 +262,68 @@ function UserBadge({ user }: { user: { displayName: string | null; email: string
/** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */
async function handleLogout() {
await logoutFn();
message.success("已退出登录");
message.success(t("已退出登录"));
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 size={14}>
<Space size={6}>
<Languages size={15} aria-hidden style={{ color: "var(--ant-color-text-tertiary)" }} />
<Select
size="small"
variant="borderless"
value={locale}
onChange={setLocale}
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
style={{ minWidth: 120 }}
aria-label={t("语言")}
/>
</Space>
<Switch
size="small"
checked={theme === "dark"}
checkedChildren={<Moon size={11} aria-hidden />}
unCheckedChildren={<Sun size={11} aria-hidden />}
onChange={(v) => setTheme(v ? "dark" : "light")}
aria-label={t("主题切换")}
/>
<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={8} style={{ cursor: "pointer" }} data-testid="user-menu">
{props.user.avatar ? (
<Avatar size={30} src={props.user.avatar} />
) : (
<Avatar size={30} icon={<UserRound size={15} aria-hidden />} />
)}
<span>{props.user.displayName || props.user.email}</span>
<Tag color={props.user.role === "admin" ? "blue" : "default"} style={{ marginInlineEnd: 0 }}>{props.user.role}</Tag>
</Space>
</Dropdown>
</Space>
);
}