Files
sundynix-pets/pets-be/web/admin/src/App.tsx
T
Blizzard 9d7cf720a5
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped
feat: add multi-source breed encyclopedia and two-hour refresh sessions
2026-10-05 13:38:24 +08:00

405 lines
13 KiB
TypeScript

import { NavLink, Navigate, useNavigate, useLocation, useParams } from "react-router-dom";
import Breeds from "@/components/admin/Breeds";
import { useEffect, useState, type FormEvent } from "react";
import {
Dog,
CalendarDays,
CheckCheck,
Files,
LayoutDashboard,
LoaderCircle,
LogOut,
PawPrint,
RefreshCw,
Route,
Users,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
api,
clearToken,
getToken,
setToken,
type Row,
type Section,
type Stats,
} from "@/lib/api";
import { labels } from "@/components/admin/format";
import Overview from "@/components/admin/Overview";
import Records from "@/components/admin/Records";
import Details from "@/components/admin/Details";
const icons = {
overview: LayoutDashboard,
users: Users,
pets: PawPrint,
breeds: Dog,
walks: Route,
meetups: CalendarDays,
tasks: CheckCheck,
files: Files,
};
type Tab = Section | "overview" | "breeds";
export default function App() {
const routerNavigate = useNavigate(), location = useLocation(), { section } = useParams();
const tab: Tab = section && section in labels ? section as Tab : "overview";
useEffect(() => { setDetail(null); setConfirm(null); setError(""); setNotice(""); setBusy(false); }, [location.pathname]);
const [token, setAuth] = useState(getToken()),
[username, setUsername] = useState("sundynix"),
[password, setPassword] = useState("");
const [revision, refresh] = useState(0),
[stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState(""),
[busy, setBusy] = useState(false),
[actionBusy, setActionBusy] = useState(false),
[notice, setNotice] = useState("");
const [detail, setDetail] = useState<{ section: Section; row: Row } | null>(
null,
),
[confirm, setConfirm] = useState<{ section: Section; row: Row } | null>(
null,
);
useEffect(() => {
const expired = () => {
setAuth("");
setDetail(null);
setConfirm(null);
setError("登录已过期,请重新登录");
};
const sync = () => setAuth(getToken());
window.addEventListener("storage", sync);
window.addEventListener("admin-auth-expired", expired);
return () => { window.removeEventListener("admin-auth-expired", expired); window.removeEventListener("storage", sync); };
}, []);
useEffect(() => {
if (!token || tab !== "overview") return;
const controller = new AbortController();
setBusy(true);
setError("");
api
.stats(controller.signal)
.then((value) => {
if (!controller.signal.aborted) setStats(value);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [tab, token, revision]);
async function login(e: FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const value = await api.login(username.trim(), password);
setToken(value);
setAuth(value.token);
setPassword("");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function logout() {
void api.logout().catch(() => {});
clearToken();
setAuth("");
setPassword("");
setStats(null);
setDetail(null);
setConfirm(null);
setError("");
setNotice("");
}
function navigate(section: Tab) {
setBusy(false);
routerNavigate(`/admin/${section}`);
setError("");
setNotice("");
}
async function applyAction() {
if (!confirm || actionBusy) return;
setActionBusy(true);
setError("");
try {
if (confirm.section === "users")
await api.setUserDisabled(confirm.row.id, !confirm.row.disabled);
else await api.cancelMeetup(confirm.row.id);
setNotice(
confirm.section === "users"
? confirm.row.disabled
? "用户已启用"
: "用户已停用,位置分享已关闭"
: "活动已取消",
);
setConfirm(null);
refresh((value) => value + 1);
} catch (e) {
setError((e as Error).message);
} finally {
setActionBusy(false);
}
}
const actionTitle =
confirm?.section === "users"
? confirm.row.disabled
? "启用用户"
: "停用用户"
: "取消约遛活动";
if (section !== "login" && !(section && Object.hasOwn(labels, section))) return <Navigate to="/admin/overview" replace />;
if (!token && section !== "login") return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
if (token && section === "login") {
const from = location.state?.from;
return <Navigate to={typeof from === "string" && /^\/admin\/(overview|users|pets|breeds|walks|meetups|tasks|files)$/.test(from) ? from : "/admin/overview"} replace />;
}
if (!token)
return (
<main className="login-page">
<section className="login-story">
<img src="/brand-logo.png" alt="肉垫计划" />
<p className="eyebrow">PAWPRINT PLAN</p>
<h1>
每一段陪伴,
<br />
都有迹可循。
</h1>
<p>
让宠物的日常更有温度,
<br />
让每一次出门都值得记录。
</p>
<div className="story-tags">
<span>
<PawPrint size={16} />
宠物档案
</span>
<span>
<Route size={16} />
快乐出门
</span>
<span>
<CheckCheck size={16} />
日常照顾
</span>
</div>
</section>
<form className="login-card" onSubmit={login}>
<span className="login-mark">
<PawPrint size={26} />
</span>
<p className="eyebrow">肉垫计划 · 管理后台</p>
<h2>欢迎回来</h2>
<p className="muted">今天也一起,照顾好每一个小日常。</p>
<label htmlFor="username">管理员账号</label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
maxLength={64}
/>
<label htmlFor="password">密码</label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
<Button disabled={busy} className="w-full login-submit">
{busy && <LoaderCircle size={16} className="animate-spin" />}
{busy ? "正在登录" : "进入管理后台"}
</Button>
<small className="login-footnote">
肉垫计划 · 给陪伴一个温暖的地方
</small>
</form>
</main>
);
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<img src="/brand-logo.png" alt="" />
<div>
<strong>肉垫计划</strong>
<small>PAWPRINT PLAN</small>
</div>
</div>
<p className="side-caption">陪伴工作台</p>
<nav aria-label="管理导航">
{(Object.keys(labels) as Tab[]).map((key) => {
const Icon = icons[key];
return (
<NavLink
to={`/admin/${key}`}
key={key}
className={`nav-item ${tab === key ? "active" : ""}`}
aria-current={tab === key ? "page" : undefined}
title={labels[key]}
>
<Icon size={19} />
<span>{labels[key]}</span>
{tab === key && <span className="nav-dot" />}
</NavLink>
);
})}
</nav>
<div className="side-note">
<PawPrint size={22} />
<p>
陪伴它们的每一天,
<br />
都值得记录。
</p>
</div>
<div className="side-bottom">
<span className="admin-indicator">管理员</span>
<Button variant="ghost" onClick={logout} title="退出登录">
<LogOut size={16} />
<span>退出登录</span>
</Button>
</div>
</aside>
<main className="content">
<header className="topbar">
<div>
<p className="eyebrow">
肉垫计划 / {tab === "overview" ? "今天的日常" : "数据管理"}
</p>
<h1>{labels[tab]}</h1>
</div>
<div className="topbar-actions">
<span className="today-date">
{new Date().toLocaleDateString("zh-CN", {
month: "long",
day: "numeric",
weekday: "long",
})}
</span>
<Button
variant="outline"
disabled={busy || actionBusy}
onClick={() => refresh((value) => value + 1)}
>
<RefreshCw size={16} className={busy ? "animate-spin" : ""} />
刷新
</Button>
<Button
variant="ghost"
className="mobile-logout"
size="icon"
onClick={logout}
aria-label="退出登录"
>
<LogOut size={18} />
</Button>
</div>
</header>
{error && !confirm && (
<div className="error-banner" role="alert">
{error}
<Button variant="ghost" size="sm" onClick={() => setError("")}>
关闭
</Button>
</div>
)}
{notice && (
<div className="notice" role="status">
<CheckCheck size={16} />
{notice}
<button aria-label="关闭提示" onClick={() => setNotice("")}>
<X size={16} />
</button>
</div>
)}
{tab === "overview" ? (
<Overview stats={stats} navigate={navigate} />
) : tab === "breeds" ? (
<Breeds revision={revision} />
) : (
<Records
key={tab}
section={tab}
revision={revision}
action={(section, row) => {
setError("");
setConfirm({ section, row });
}}
detail={(section, row) => setDetail({ section, row })}
actionBusy={actionBusy}
/>
)}
<footer className="page-footer">
肉垫计划 · 把每一份陪伴,认真记下来
</footer>
</main>
<Details value={detail} close={() => setDetail(null)} />
<Dialog
open={!!confirm}
onOpenChange={(open) => {
if (!open && !actionBusy) {
setConfirm(null);
setError("");
}
}}
>
<DialogContent aria-describedby="action-description">
<DialogHeader>
<DialogTitle>{actionTitle}</DialogTitle>
</DialogHeader>
<p id="action-description" className="confirm-copy">
{confirm?.section === "users"
? confirm.row.disabled
? `确认恢复「${confirm.row.nickname || "该用户"}」的访问权限?`
: `停用「${confirm.row.nickname || "该用户"}」后,将无法使用小程序接口,现有位置分享也会关闭。`
: `确认取消「${confirm?.row.title}」?取消后活动将停止接受报名。`}
</p>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
<div className="dialog-actions">
<Button
variant="outline"
disabled={actionBusy}
onClick={() => {
setConfirm(null);
setError("");
}}
>
暂不操作
</Button>
<Button disabled={actionBusy} onClick={() => void applyAction()}>
{actionBusy && (
<LoaderCircle size={16} className="animate-spin" />
)}
确认{actionTitle}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}