Complete admin dashboard with paginated user pet trees and walk relations
This commit is contained in:
+307
-262
@@ -1,64 +1,102 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Users,
|
||||
PawPrint,
|
||||
Route,
|
||||
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 { api, clearToken, getToken, setToken } from "@/lib/api";
|
||||
|
||||
type Tab = "overview" | "users" | "pets" | "walks" | "meetups";
|
||||
const labels: Record<Tab, string> = {
|
||||
overview: "总览",
|
||||
users: "用户",
|
||||
pets: "宠物",
|
||||
walks: "遛狗记录",
|
||||
meetups: "约遛活动",
|
||||
};
|
||||
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: PawPrint,
|
||||
overview: LayoutDashboard,
|
||||
users: Users,
|
||||
pets: PawPrint,
|
||||
walks: Route,
|
||||
meetups: CalendarDays,
|
||||
tasks: CheckCheck,
|
||||
files: Files,
|
||||
};
|
||||
const fmt = (v: string) => (v ? new Date(v).toLocaleString("zh-CN") : "—");
|
||||
export default function App() {
|
||||
const [token, auth] = useState(getToken()),
|
||||
const [token, setAuth] = useState(getToken()),
|
||||
[username, setUsername] = useState("sundynix"),
|
||||
[password, setPassword] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("overview"),
|
||||
[items, setItems] = useState<any[]>([]),
|
||||
[stats, setStats] = useState<Record<string, number>>({});
|
||||
const [tab, setTab] = useState<Section | "overview">("overview"),
|
||||
[revision, refresh] = useState(0),
|
||||
[stats, setStats] = useState<Stats | null>(null);
|
||||
const [error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function load() {
|
||||
[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("登录已过期,请重新登录");
|
||||
};
|
||||
window.addEventListener("admin-auth-expired", expired);
|
||||
return () => window.removeEventListener("admin-auth-expired", expired);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!token || tab !== "overview") return;
|
||||
const controller = new AbortController();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (tab === "overview") setStats(await api.stats());
|
||||
else setItems(await api[tab]());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (token) void load();
|
||||
}, [tab, token]);
|
||||
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 r = await api.login(username, password);
|
||||
setToken(r.token);
|
||||
auth(r.token);
|
||||
const value = await api.login(username.trim(), password);
|
||||
setToken(value.token);
|
||||
setAuth(value.token);
|
||||
setPassword("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
@@ -67,55 +105,117 @@ export default function App() {
|
||||
}
|
||||
function logout() {
|
||||
clearToken();
|
||||
auth("");
|
||||
setAuth("");
|
||||
setPassword("");
|
||||
setStats(null);
|
||||
setDetail(null);
|
||||
setConfirm(null);
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
async function toggle(row: any) {
|
||||
function navigate(section: Section | "overview") {
|
||||
setBusy(false);
|
||||
setTab(section);
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
async function applyAction() {
|
||||
if (!confirm || actionBusy) return;
|
||||
setActionBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.setUserDisabled(row.id, !row.disabled);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function cancel(row: any) {
|
||||
if (!window.confirm(`取消「${row.title}」?`)) return;
|
||||
try {
|
||||
await api.setMeetupStatus(row.id, "cancelled");
|
||||
await load();
|
||||
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 (!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}>
|
||||
<div className="login-mark">🐾</div>
|
||||
<p className="eyebrow">肉垫计划 · 管理端</p>
|
||||
<h1>欢迎回来</h1>
|
||||
<p className="muted">照顾每一只小短腿的快乐日常。</p>
|
||||
<label>
|
||||
用户名
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<Button disabled={busy} className="w-full">
|
||||
登录管理后台
|
||||
<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>
|
||||
);
|
||||
@@ -123,219 +223,164 @@ export default function App() {
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span>🐾</span>
|
||||
<img src="/brand-logo.png" alt="" />
|
||||
<div>
|
||||
<strong>肉垫计划</strong>
|
||||
<small>ADMIN STUDIO</small>
|
||||
<small>PAWPRINT PLAN</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="side-caption">工作台</p>
|
||||
<nav>
|
||||
{(Object.keys(labels) as Tab[]).map((key) => {
|
||||
<p className="side-caption">陪伴工作台</p>
|
||||
<nav aria-label="管理导航">
|
||||
{(Object.keys(labels) as (Section | "overview")[]).map((key) => {
|
||||
const Icon = icons[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={tab === key ? "nav-item active" : "nav-item"}
|
||||
onClick={() => setTab(key)}
|
||||
className={`nav-item ${tab === key ? "active" : ""}`}
|
||||
aria-current={tab === key ? "page" : undefined}
|
||||
title={labels[key]}
|
||||
onClick={() => navigate(key)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{labels[key]}
|
||||
<Icon size={19} />
|
||||
<span>{labels[key]}</span>
|
||||
{tab === key && <span className="nav-dot" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="side-note">
|
||||
<PawPrint size={22} />
|
||||
<p>
|
||||
陪伴它们的每一天,
|
||||
<br />
|
||||
都值得记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="side-bottom">
|
||||
<span>让每次出门都值得记录</span>
|
||||
<button onClick={logout}>
|
||||
<LogOut size={16} /> 退出登录
|
||||
</button>
|
||||
<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">PAWPRINT DASHBOARD</p>
|
||||
<p className="eyebrow">
|
||||
肉垫计划 / {tab === "overview" ? "今天的日常" : "数据管理"}
|
||||
</p>
|
||||
<h1>{labels[tab]}</h1>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={busy}>
|
||||
<RefreshCw size={16} /> 刷新
|
||||
</Button>
|
||||
<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 && <div className="error-banner">{error}</div>}
|
||||
{tab === "overview" ? (
|
||||
<>
|
||||
<div className="hero">
|
||||
<div>
|
||||
<p>每一段陪伴,都有迹可循</p>
|
||||
<h2>你好,铲屎官管理者 👋</h2>
|
||||
<span>这里汇总肉垫计划的日常活动。</span>
|
||||
</div>
|
||||
<div className="hero-paw">🐕</div>
|
||||
</div>
|
||||
<div className="stat-grid">
|
||||
{(
|
||||
[
|
||||
["users", "注册用户", "👥"],
|
||||
["pets", "宠物档案", "🐶"],
|
||||
["walks", "遛狗记录", "🐾"],
|
||||
["meetups", "约遛活动", "🌿"],
|
||||
] as const
|
||||
).map(([key, label, icon]) => (
|
||||
<article className="stat-card" key={key}>
|
||||
<div className="stat-icon">{icon}</div>
|
||||
<span>{label}</span>
|
||||
<strong>{stats[key] ?? "—"}</strong>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<section className="table-card">
|
||||
<div className="table-head">
|
||||
<h2>{labels[tab]}</h2>
|
||||
<p>最近 {items.length} 条记录</p>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{tab === "users" ? (
|
||||
<>
|
||||
<th>用户</th>
|
||||
<th>注册时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</>
|
||||
) : tab === "pets" ? (
|
||||
<>
|
||||
<th>宠物</th>
|
||||
<th>品种</th>
|
||||
<th>主人 ID</th>
|
||||
<th>状态</th>
|
||||
</>
|
||||
) : tab === "walks" ? (
|
||||
<>
|
||||
<th>记录 ID</th>
|
||||
<th>用户 ID</th>
|
||||
<th>开始时间</th>
|
||||
<th>距离</th>
|
||||
<th>状态</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th>活动</th>
|
||||
<th>地点</th>
|
||||
<th>时间</th>
|
||||
<th>人数</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{tab === "users" ? (
|
||||
<>
|
||||
<td>
|
||||
<b>{row.nickname}</b>
|
||||
<small>{row.id}</small>
|
||||
</td>
|
||||
<td>{fmt(row.created_at)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
"pill " + (row.disabled ? "red" : "green")
|
||||
}
|
||||
>
|
||||
{row.disabled ? "已停用" : "正常"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void toggle(row)}
|
||||
>
|
||||
{row.disabled ? "启用" : "停用"}
|
||||
</Button>
|
||||
</td>
|
||||
</>
|
||||
) : tab === "pets" ? (
|
||||
<>
|
||||
<td>
|
||||
<b>{row.name}</b>
|
||||
<small>{row.id}</small>
|
||||
</td>
|
||||
<td>{row.breed || row.species}</td>
|
||||
<td className="mono">{row.user_id}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
"pill " + (row.archived ? "red" : "green")
|
||||
}
|
||||
>
|
||||
{row.archived ? "已归档" : "在用"}
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
) : tab === "walks" ? (
|
||||
<>
|
||||
<td className="mono">{row.id}</td>
|
||||
<td className="mono">{row.user_id}</td>
|
||||
<td>{fmt(row.started_at)}</td>
|
||||
<td>{(row.distance_meters / 1000).toFixed(2)} km</td>
|
||||
<td>
|
||||
<span className="pill green">{row.status}</span>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>
|
||||
<b>{row.title}</b>
|
||||
<small>{row.id}</small>
|
||||
</td>
|
||||
<td>{row.place_name}</td>
|
||||
<td>{fmt(row.starts_at)}</td>
|
||||
<td>
|
||||
{row.participants}/{row.max_people}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
"pill " +
|
||||
(row.status === "open" ? "green" : "red")
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{row.status === "open" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancel(row)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{items.length === 0 && (
|
||||
<div className="empty">还没有记录,新的足迹会出现在这里 🐾</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{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} />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user