v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* # 路由 /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 {
|
||||
Files,
|
||||
Inbox,
|
||||
Image as ImageIcon,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { StyleProvider } from "@ant-design/cssinjs";
|
||||
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||||
|
||||
/** # 类型 后台菜单项(key 即目标路由路径) **/
|
||||
const MENU_ITEMS = [
|
||||
{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" },
|
||||
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "内容" },
|
||||
{ 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: "询盘" },
|
||||
{ key: "/admin/media/", icon: <ImageIcon 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/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
|
||||
{ key: "/admin/users/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||||
] as const;
|
||||
|
||||
/** # 类型 合法菜单路径(用于类型安全的导航) **/
|
||||
type MenuKey = (typeof MENU_ITEMS)[number]["key"];
|
||||
|
||||
/** # 类型 拥有后台权限的角色 **/
|
||||
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} />;
|
||||
}
|
||||
|
||||
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码) */
|
||||
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
/** # 函数 登录提交:调用 loginFn,成功回调 onOk,失败展示错误 */
|
||||
async function handleFinish(values: { email: string; password: string }) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await loginFn({ data: { email: values.email, password: values.password } });
|
||||
if (!res.ok) setError(res.error);
|
||||
else await props.onOk();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
|
||||
} 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>
|
||||
{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_ITEMS.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user