mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 07:13:43 +08:00
Initial
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
BoardRegular,
|
||||
DocumentTextRegular,
|
||||
GlobeRegular,
|
||||
MailRegular,
|
||||
PanelLeftContractRegular,
|
||||
PanelLeftExpandRegular,
|
||||
RouterRegular,
|
||||
SettingsRegular,
|
||||
SignOutRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { useAuth } from "../../store/auth";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { confirmDialog } from "../ui/MessageBox";
|
||||
import { LanguageSwitch } from "../ui/LanguageSwitch";
|
||||
import { SwitchDark } from "../ui/SwitchDark";
|
||||
import { Drawer } from "../ui/Drawer";
|
||||
import { ErrorBoundary } from "../ui/ErrorBoundary";
|
||||
import { cx } from "../../lib/utils";
|
||||
import { BrandLogo } from "./BrandLogo";
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "仪表盘", icon: BoardRegular, end: true },
|
||||
{ to: "/devices", label: "设备管理", icon: RouterRegular },
|
||||
{ to: "/proxy", label: "代理管理", icon: GlobeRegular },
|
||||
{ to: "/sms", label: "短信检测", icon: MailRegular },
|
||||
{ to: "/logs", label: "实时日志", icon: DocumentTextRegular },
|
||||
{ to: "/settings", label: "系统设置", icon: SettingsRegular },
|
||||
];
|
||||
|
||||
export function AuthenticatedShell({
|
||||
isDark,
|
||||
onToggleTheme,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
onToggleTheme: () => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { logout, user } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)");
|
||||
const update = () => {
|
||||
setIsMobile(mq.matches);
|
||||
if (!mq.matches) setMobileOpen(false);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update, { passive: true });
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
async function onLogout() {
|
||||
const ok = await confirmDialog(t("确认退出登录?"), t("提示"), {
|
||||
confirmText: t("退出"),
|
||||
cancelText: t("取消"),
|
||||
type: "warning",
|
||||
});
|
||||
if (ok) {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isMobile) setMobileOpen(true);
|
||||
else setCollapsed((value) => !value);
|
||||
}
|
||||
|
||||
function menuList(collapse: boolean) {
|
||||
return (
|
||||
<nav className={cx("sidebar-menu mt-2", collapse && "is-collapsed")} aria-label={t("主导航")}>
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const label = t(item.label);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
title={collapse ? label : undefined}
|
||||
className={({ isActive }) => cx("vocat-menu-item", isActive && "is-active")}
|
||||
>
|
||||
<span className="vocat-menu-icon">
|
||||
<Icon />
|
||||
</span>
|
||||
<span className="sidebar-menu-label">{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function userCard() {
|
||||
return (
|
||||
<div className="ui-panel-muted flex items-center gap-3 p-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
|
||||
<SettingsRegular className="h-[18px] w-[18px]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold">{user?.username || "Admin"}</div>
|
||||
<div className="truncate text-xs text-gray-400">{user?.role || "Administrator"}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
aria-label={t("退出登录")}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<SignOutRegular className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{!isMobile && (
|
||||
<aside
|
||||
className={cx(
|
||||
"ui-glass sidebar-shell relative h-full transition-[width] duration-200",
|
||||
collapsed ? "w-[52px]" : "w-[232px]",
|
||||
)}
|
||||
>
|
||||
<div className={cx("flex h-14 items-center px-4", collapsed && "justify-center px-0")}>
|
||||
<BrandLogo className="sidebar-brand-logo" />
|
||||
{!collapsed && (
|
||||
<div className="ml-3">
|
||||
<div className="sidebar-brand-title">vocat</div>
|
||||
<div className="text-[10px] font-medium leading-tight tracking-wide text-gray-400 dark:text-gray-500">{t("EC20 出厂检测工具")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{menuList(collapsed)}
|
||||
{!collapsed && <div className="absolute bottom-4 w-full px-3">{userCard()}</div>}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<Drawer open={isMobile && mobileOpen} onClose={() => setMobileOpen(false)} className="mobile-drawer">
|
||||
<div className="sidebar-shell relative h-full bg-white/95 backdrop-blur-md dark:bg-[#141418]/95">
|
||||
<div className="flex h-16 items-center px-4">
|
||||
<BrandLogo className="sidebar-brand-logo" />
|
||||
<div className="ml-3">
|
||||
<div className="sidebar-brand-title">vocat</div>
|
||||
<div className="text-[10px] font-medium leading-tight tracking-wide text-gray-400 dark:text-gray-500">{t("EC20 出厂检测工具")}</div>
|
||||
</div>
|
||||
</div>
|
||||
{menuList(false)}
|
||||
<div className="absolute bottom-4 w-full px-3">{userCard()}</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<header className="ui-glass sticky top-0 z-10 flex h-14 items-center justify-between border-b border-gray-100 px-4 dark:border-white/5 sm:px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={collapsed ? t("展开侧栏") : t("收起侧栏")}
|
||||
className="rounded-lg px-2 py-1.5 text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
||||
>
|
||||
{!isMobile && !collapsed ? (
|
||||
<PanelLeftContractRegular className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelLeftExpandRegular className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitch />
|
||||
<SwitchDark isDark={isDark} onToggle={onToggleTheme} />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto bg-gray-50/50 p-4 dark:bg-transparent sm:p-6">
|
||||
<div className="main-inner mx-auto w-full">
|
||||
<ErrorBoundary title={t("页面渲染失败")}>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function BrandLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M326.255787 457.728c23.637333 0.597333 37.717333 15.872 37.461333 40.533333a40.362667 40.362667 0 0 1-40.021333 42.069334c-20.138667-0.938667-34.816-18.602667-34.645334-41.642667 0.170667-24.832 15.274667-41.472 37.205334-40.96z" fill="#1296db" />
|
||||
<path d="M982.127787 565.333333c-11.605333 0-17.066667-2.389333-17.066667-15.786666a377.941333 377.941333 0 0 0-33.28-143.872 46.506667 46.506667 0 0 1-2.474667-27.562667c11.178667-64.597333 21.930667-129.28 30.805334-194.218667 4.949333-36.181333-12.885333-49.322667-47.36-37.290666-54.357333 18.858667-108.714667 37.888-162.730667 57.685333a44.714667 44.714667 0 0 1-34.133333-0.853333c-131.669333-50.773333-263.594667-52.394667-395.264 0a47.36 47.36 0 0 1-36.608 0.682666c-51.797333-19.029333-103.936-37.12-155.989334-55.466666-42.666667-14.933333-59.136-0.938667-52.053333 44.288s13.824 90.112 22.357333 134.826666c7.509333 39.594667 11.008 77.312-7.936 115.882667a217.770667 217.770667 0 0 0-19.797333 88.32c-0.768 21.162667-3.328 35.498667-25.6 28.416H26.82112c-13.653333 1.450667-26.112 6.144-26.794667 22.101333-0.682667 17.066667 11.946667 23.125333 26.709334 24.576a171.434667 171.434667 0 0 0 28.842666 0.512c11.690667-0.768 17.066667 3.242667 19.029334 15.445334a220.245333 220.245333 0 0 0 28.928 80.810666c7.253333 12.288 7.253333 19.370667-6.4 26.282667a309.76 309.76 0 0 0-40.704 25.6c-11.178667 8.021333-18.346667 18.944-9.898667 32.938667s21.248 13.312 33.621333 6.570666c16.128-8.533333 31.744-18.602667 47.104-28.672 9.045333-5.973333 15.274667-7.936 24.490667 1.194667 51.2 51.2 114.602667 80.725333 184.661333 96.426667 100.352 22.528 201.898667 20.309333 303.274667 10.325333a417.450667 417.450667 0 0 0 195.242667-67.754667c17.834667-12.032 32.768-34.133333 51.2-37.376s36.522667 19.712 55.552 29.610667l2.133333 1.109333c12.714667 5.632 25.6 7.594667 34.133333-6.314666s1.962667-24.917333-9.301333-33.024a335.786667 335.786667 0 0 0-38.4-24.234667c-11.178667-5.973333-12.032-11.605333-5.205333-22.101333a230.4 230.4 0 0 0 33.621333-84.138667c2.645333-14.336 8.533333-18.176 21.76-18.090667 18.602667 0 43.776 3.84 43.776-23.893333-0.085333-26.026667-23.893333-23.210667-42.069333-22.954667z m-83.541334 1.450667a458.837333 458.837333 0 0 0-55.210666 1.365333c-13.738667 0.768-23.210667 7.68-22.954667 22.357334a21.674667 21.674667 0 0 0 23.466667 22.186666 436.309333 436.309333 0 0 0 52.821333 0c17.066667-1.536 19.029333 5.546667 13.994667 19.114667-3.072 8.533333-5.376 17.066667-8.533334 24.917333-18.346667 47.189333-18.517333 47.274667-62.208 24.661334-13.653333-7.082667-26.624-9.642667-35.498666 5.546666s0 25.6 12.458666 34.133334c29.184 20.906667 29.184 21.077333 0 41.813333-64 45.738667-137.472 65.792-214.101333 69.376-91.648 4.352-184.149333 8.533333-274.261333-17.066667a355.242667 355.242667 0 0 1-129.450667-65.792c-13.397333-11.178667-18.858667-19.285333 1.194667-28.074666a56.149333 56.149333 0 0 0 13.909333-9.386667c9.301333-8.533333 13.141333-18.346667 5.888-29.781333a20.650667 20.650667 0 0 0-29.098667-8.021334c-3.669333 1.706667-7.168 3.669333-10.666666 5.632-30.549333 17.066667-30.634667 17.066667-44.544-14.592a167.253333 167.253333 0 0 1-16.213334-57.002666h58.282667c14.506667 0 25.6-6.229333 26.197333-22.016S194.074453 563.882667 179.311787 563.2a292.864 292.864 0 0 0-42.666667-1.194667c-21.504 2.133333-23.125333-7.594667-20.736-25.6a426.666667 426.666667 0 0 1 34.133333-121.856 54.272 54.272 0 0 0 3.669334-34.389333c-9.984-55.125333-17.578667-110.933333-27.477334-165.632-3.242667-17.92 2.133333-17.066667 15.36-12.117333 48.128 17.493333 96.597333 34.133333 144.554667 51.2a49.066667 49.066667 0 0 0 41.386667-1.877334A397.141333 397.141333 0 0 1 501.44512 213.333333c73.728-0.597333 145.066667 6.656 211.797333 41.813334a40.96 40.96 0 0 0 34.730667-0.768c48.042667-17.066667 96.426667-34.133333 145.066667-51.2a23.808 23.808 0 0 1 17.834666-2.304c-9.472 59.050667-18.090667 118.186667-29.013333 176.896a64.512 64.512 0 0 0 4.778667 41.301333 429.056 429.056 0 0 1 33.536 124.672c1.536 17.237333-2.730667 24.234667-21.589334 23.04z" fill="#1296db" />
|
||||
<path d="M516.634453 535.296c42.666667 0.853333 59.221333 10.581333 59.221334 35.925333s-31.146667 57.344-55.210667 56.490667c-29.184-0.938667-60.16-31.573333-59.733333-58.794667 0.426667-23.381333 18.517333-34.304 55.722666-33.621333zM713.15712 462.592C737.050453 463.189333 751.04512 477.866667 751.04512 503.466667a40.533333 40.533333 0 0 1-40.106667 42.069333c-20.053333-0.853333-34.901333-18.688-34.645333-41.642667-0.085333-25.344 15.018667-41.813333 36.864-41.301333z" fill="#1296db" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { LanguageSwitch } from "../ui/LanguageSwitch";
|
||||
import { SwitchDark } from "../ui/SwitchDark";
|
||||
|
||||
// UnauthenticatedShell: centers the login card, theme toggle pinned top-right.
|
||||
export function UnauthenticatedShell({
|
||||
isDark,
|
||||
onToggleTheme,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
onToggleTheme: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-100 transition-colors duration-300 dark:bg-gray-950">
|
||||
<div className="absolute right-4 top-4 z-50 flex items-center gap-2">
|
||||
<LanguageSwitch />
|
||||
<SwitchDark isDark={isDark} onToggle={onToggleTheme} />
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user