405 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|