v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+206
View File
@@ -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>
);
}