v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON
This commit is contained in:
+211
-143
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user