Complete admin dashboard with paginated user pet trees and walk relations
ci-cd / verify (push) Successful in 1m40s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-04 11:45:15 +08:00
parent 3655a6a89e
commit 6c37785ce1
26 changed files with 3155 additions and 601 deletions
+307 -262
View File
@@ -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>
);
}
@@ -0,0 +1,260 @@
import { useEffect, useState } from "react";
import { ExternalLink, LoaderCircle, Route } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { api, type Point, type Row, type Section } from "@/lib/api";
import { Identity, Status } from "./common";
import { bytes, categories, duration, fmt } from "./format";
function Track({ points }: { points: Point[] }) {
const valid = points.filter(
(p) => Number.isFinite(p.latitude) && Number.isFinite(p.longitude),
);
if (!valid.length)
return (
<div className="empty compact">
<Route size={28} />
这条记录尚未采集足迹
</div>
);
const origin = valid[0];
const coords = valid.map((p) => [
(p.longitude - origin.longitude) *
Math.cos((origin.latitude * Math.PI) / 180),
p.latitude - origin.latitude,
]);
const xs = coords.map((p) => p[0]),
ys = coords.map((p) => p[1]);
const minX = Math.min(...xs),
maxX = Math.max(...xs),
minY = Math.min(...ys),
maxY = Math.max(...ys);
const scale = Math.min(
440 / (maxX - minX || 1e-6),
200 / (maxY - minY || 1e-6),
);
const normalized = coords.map(([x, y]) => [
250 + (x - (minX + maxX) / 2) * scale,
120 - (y - (minY + maxY) / 2) * scale,
]);
const [first, last] = [normalized[0], normalized[normalized.length - 1]];
return (
<div className="track">
<svg viewBox="0 0 500 240" role="img" aria-label="遛狗足迹路线示意">
<defs>
<pattern
id="grid"
width="25"
height="25"
patternUnits="userSpaceOnUse"
>
<path
d="M 25 0 L 0 0 0 25"
fill="none"
stroke="#eaded3"
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="500" height="240" fill="url(#grid)" />
<polyline
points={normalized.map((p) => p.join(",")).join(" ")}
fill="none"
stroke="#b95738"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx={first[0]}
cy={first[1]}
r="7"
fill="#8aa2bd"
stroke="white"
strokeWidth="3"
/>
<circle
cx={last[0]}
cy={last[1]}
r="7"
fill="#b95738"
stroke="white"
strokeWidth="3"
/>
</svg>
<p>足迹路线示意 · 蓝色起点 / 橙色终点 · {valid.length} 个位置点</p>
</div>
);
}
export default function Details({
value,
close,
}: {
value: { section: Section; row: Row } | null;
close: () => void;
}) {
const [points, setPoints] = useState<Point[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
useEffect(() => {
setPoints([]);
setError("");
setBusy(false);
if (!value || value.section !== "walks") return;
const controller = new AbortController();
setBusy(true);
api
.walk(value.row.id, controller.signal)
.then((result) => {
if (!controller.signal.aborted) setPoints(result.points);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [value]);
const row = value?.row,
section = value?.section;
const entries: [string, string | number | undefined][] = !row
? []
: section === "pets"
? [
["主人", row.user_name],
["主人 ID", row.user_id],
["品种", row.breed || row.species],
[
"性别",
row.gender === "male"
? "男孩"
: row.gender === "female"
? "女孩"
: "未填写",
],
["生日", row.birth_date],
["体重", row.weight_kg ? `${row.weight_kg} kg` : "未填写"],
["毛色", row.coat],
["过敏提醒", row.allergies],
["照顾备注", row.care_notes],
["创建时间", fmt(row.created_at)],
]
: section === "walks"
? [
["遛狗人", row.user_name],
["用户 ID", row.user_id],
["同行宠物", row.pet_names?.join("、") || "—"],
["开始时间", fmt(row.started_at)],
["结束时间", fmt(row.ended_at)],
["距离", `${((row.distance_meters || 0) / 1000).toFixed(2)} km`],
["已记录时长", duration(row.duration_seconds)],
["位置分享", row.share_enabled ? "已开启" : "未开启"],
]
: section === "meetups"
? [
["发起人", row.user_name],
["集合地点", row.place_name],
["活动时间", fmt(row.starts_at)],
["参与人数", `${row.participants}/${row.max_people}`],
["活动说明", row.description],
["坐标", `${row.longitude}, ${row.latitude}`],
]
: section === "tasks"
? [
["用户", row.user_name],
["宠物", row.pet_name],
["类别", categories[row.category || ""] || row.category],
["计划时间", row.time_of_day],
["重复规则", row.repeat_rule === "daily" ? "每天" : "单次"],
["创建时间", fmt(row.created_at)],
]
: [
["文件类型", row.content_type],
["大小", bytes(row.size)],
["MD5", row.md5],
["存储桶", row.bucket],
["对象路径", row.object_key],
["文件 ID", row.id],
["上传时间", fmt(row.created_at)],
];
return (
<Dialog
open={!!value}
onOpenChange={(open) => {
if (!open) close();
}}
>
<DialogContent className="detail-dialog" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
{row?.name ||
row?.title ||
(section === "walks" ? "遛狗详情" : "记录详情")}
</DialogTitle>
</DialogHeader>
{row && (
<>
<div className="detail-summary">
<Identity
name={row.name || row.title || row.user_name}
id={row.id}
url={row.avatar_url}
/>
<Status row={row} section={section!} />
</div>
{section === "files" && row.url && (
<>
<img
className="file-preview"
src={row.url}
alt={row.name || "文件预览"}
/>
<a
href={row.url}
target="_blank"
rel="noreferrer"
className="file-link"
>
查看原图
<ExternalLink size={14} />
</a>
</>
)}
<dl className="detail-grid">
{entries.map(([label, text]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{text || "—"}</dd>
</div>
))}
</dl>
{section === "walks" &&
(busy ? (
<div className="empty compact">
<LoaderCircle className="animate-spin" />
加载足迹中
</div>
) : error ? (
<p className="error-banner" role="alert">
{error}
</p>
) : (
<Track points={points} />
))}
<div className="dialog-actions">
<Button variant="outline" onClick={close}>
关闭
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,145 @@
import {
CalendarDays,
CheckCheck,
Files,
PawPrint,
Route,
Users,
ArrowUpRight,
} from "lucide-react";
import type { Section, Stats } from "@/lib/api";
import { bytes, labels } from "./format";
const metrics = [
{ key: "users", icon: Users, sub: "每一位养宠伙伴" },
{ key: "pets", icon: PawPrint, sub: "记录它们的成长" },
{ key: "walks", icon: Route, sub: "一起走过的路" },
{ key: "meetups", icon: CalendarDays, sub: "相约下一次出发" },
{ key: "tasks", icon: CheckCheck, sub: "照顾好每一个日常" },
{ key: "files", icon: Files, sub: "留住温暖的瞬间" },
] as const;
export default function Overview({
stats,
navigate,
}: {
stats: Stats | null;
navigate: (section: Section) => void;
}) {
const max = Math.max(
1,
...(stats?.daily_walks || []).map((day) => day.count),
);
return (
<>
<section className="welcome">
<div>
<p className="eyebrow">A LITTLE CARE, EVERY DAY</p>
<h2>把小日常,照顾成好日子。</h2>
<p>从一张宠物档案,到每一次快乐出门。</p>
</div>
<img src="/brand-logo.png" alt="肉垫计划" />
</section>
<div className="stat-grid">
{metrics.map(({ key, icon: Icon, sub }) => (
<button
key={key}
className={`stat-card tint-${key}`}
onClick={() => navigate(key)}
>
<div className="stat-top">
<span className="stat-icon">
<Icon size={20} />
</span>
<ArrowUpRight size={16} />
</div>
<span>{labels[key]}</span>
<strong>{stats?.[key] ?? "—"}</strong>
<small>{sub}</small>
</button>
))}
</div>
<div className="overview-grid">
<section className="panel">
<header className="panel-heading">
<div>
<h2>这周,一起走了多少次</h2>
<p>最近 7 天的遛狗记录</p>
</div>
<Route size={20} />
</header>
<div className="bar-chart" role="img" aria-label="最近七天遛狗次数">
{(stats?.daily_walks || []).map((day) => (
<div className="chart-column" key={day.date}>
<span>{day.count}</span>
<div className="bar-space">
<div
className="chart-bar"
style={{
height: `${(day.count / max) * 100}%`,
minHeight: day.count ? 6 : 2,
}}
title={`${day.date}:${day.count}次`}
/>
</div>
<small>{day.date.slice(5).replace("-", "/")}</small>
</div>
))}
{!stats && <div className="empty">正在加载趋势</div>}
</div>
</section>
<section className="panel today">
<header className="panel-heading">
<div>
<h2>今天的陪伴</h2>
<p>活动与日常一目了然</p>
</div>
<CalendarDays size={20} />
</header>
<dl>
<div>
<dt>今日出门</dt>
<dd>
{stats?.today_walks ?? "—"}
<small>次</small>
</dd>
</div>
<div>
<dt>正在遛狗 / 暂停中</dt>
<dd>
{stats?.active_walks ?? "—"}
<small>次</small>
</dd>
</div>
<div>
<dt>待开始的约遛</dt>
<dd>
{stats?.upcoming_meetups ?? "—"}
<small>场</small>
</dd>
</div>
<div>
<dt>今日任务已完成</dt>
<dd>
{stats?.today_completions ?? "—"}
<small>项</small>
</dd>
</div>
</dl>
</section>
</div>
<section className="summary-strip">
<div>
<Route size={18} />
<span>累计遛狗距离</span>
<strong>
{stats ? (stats.distance_meters / 1000).toFixed(2) : "—"} km
</strong>
</div>
<div>
<Files size={18} />
<span>文件存储用量</span>
<strong>{stats ? bytes(stats.storage_bytes) : "—"}</strong>
</div>
</section>
</>
);
}
@@ -0,0 +1,508 @@
import { Fragment, useEffect, useState } from "react";
import {
ChevronDown,
ChevronRight,
ChevronLeft,
Search,
LoaderCircle,
PawPrint,
Inbox,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { api, type Row, type PageResult, type Section } from "@/lib/api";
import { Identity, Status } from "./common";
import {
bytes,
categories,
duration,
filters,
fmt,
labels,
placeholders,
} from "./format";
function UserRow({
row,
action,
detail,
disabled,
}: {
row: Row;
action: (row: Row) => void;
detail: (section: Section, row: Row) => void;
disabled: boolean;
}) {
const [expanded, setExpanded] = useState(false),
[pets, setPets] = useState<Row[] | null>(null),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[retry, setRetry] = useState(0);
useEffect(() => {
if (!expanded || pets) return;
const controller = new AbortController();
setBusy(true);
setError("");
api
.userPets(row.id, controller.signal)
.then((result) => {
if (!controller.signal.aborted) { setPets(result); setBusy(false); }
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [expanded, row.id, pets, retry]);
return (
<Fragment>
<TableRow>
<TableCell>
<button
className="tree-toggle"
aria-expanded={expanded}
aria-label={`${expanded ? "收起" : "展开"}${row.nickname || "用户"}的宠物`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
</TableCell>
<TableCell>
<Identity
name={row.nickname}
id={row.id}
url={row.avatar_url || ""}
person
/>
</TableCell>
<TableCell>
<button
className="text-action"
onClick={() => setExpanded(!expanded)}
>
{row.pet_count || 0} 只宠物
</button>
</TableCell>
<TableCell>{fmt(row.created_at)}</TableCell>
<TableCell>
<Status row={row} section="users" />
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
disabled={disabled}
onClick={() => action(row)}
>
{row.disabled ? "启用用户" : "停用用户"}
</Button>
</TableCell>
</TableRow>
{expanded && (
<TableRow className="tree-row">
<TableCell colSpan={6}>
<div className="pet-tree">
<div className="tree-heading">
<PawPrint size={16} />
<strong>{row.nickname || "用户"}的宠物</strong>
<span>包括已归档档案</span>
</div>
{busy ? (
<div className="tree-message">
<LoaderCircle size={16} className="animate-spin" />
加载宠物中
</div>
) : error ? (
<div className="tree-message error-text">
{error}
<Button
size="sm"
variant="outline"
onClick={() => setRetry(retry + 1)}
>
重试
</Button>
</div>
) : !pets?.length ? (
<div className="tree-message">该用户还没有添加宠物</div>
) : (
<div className="pet-tree-grid">
{pets.map((pet) => (
<button
className="pet-tree-card"
key={pet.id}
onClick={() => detail("pets", pet)}
>
<Identity
name={pet.name}
id={pet.breed || "未填写品种"}
url={pet.avatar_url || ""}
/>
<Status row={pet} section="pets" />
<ChevronRight size={16} />
</button>
))}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
}
const headings: Record<Section, string[]> = {
users: ["", "用户", "宠物", "注册时间", "状态", "操作"],
pets: ["宠物", "品种 / 性别", "主人", "体重", "状态", "操作"],
walks: ["遛狗人", "同行宠物", "开始时间", "距离 / 时长", "状态", "操作"],
meetups: ["活动", "发起人", "地点 / 时间", "人数", "状态", "操作"],
tasks: ["任务", "用户 / 宠物", "类别", "计划时间", "今日状态", "操作"],
files: ["文件", "类型", "大小", "上传时间", "操作"],
};
export default function Records({
section,
revision,
action,
detail,
actionBusy,
}: {
section: Section;
revision: number;
action: (section: Section, row: Row) => void;
detail: (section: Section, row: Row) => void;
actionBusy: boolean;
}) {
const [page, setPage] = useState(1),
[query, setQuery] = useState(""),
[draft, setDraft] = useState(""),
[status, setStatus] = useState("");
const [data, setData] = useState<PageResult | null>(null),
[busy, setBusy] = useState(true),
[error, setError] = useState(""),
[retry, setRetry] = useState(0);
useEffect(() => {
const controller = new AbortController();
setBusy(true);
setError("");
setData(null);
api
.list(
section,
{ page, page_size: 10, q: query, status },
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) return;
if (page > Math.max(1, Math.ceil(result.total / 10))) {
setPage(Math.max(1, Math.ceil(result.total / 10)));
return;
}
setData(result);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [section, page, query, status, revision, retry]);
const pages = Math.max(1, Math.ceil((data?.total || 0) / 10));
return (
<section className="panel records">
<div className="records-heading">
<div>
<h2>{labels[section]}</h2>
<p>
{data ? `共 ${data.total} 条记录` : "读取记录中"}
{section === "users" && " · 展开用户查看宠物"}
</p>
</div>
<span className="per-page">每页 10 条</span>
</div>
<div className="toolbar">
<form
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setQuery(draft.trim());
setRetry(retry + 1);
}}
className="search-form"
>
<Search size={17} />
<Input
value={draft}
maxLength={128}
onChange={(e) => setDraft(e.target.value)}
placeholder={placeholders[section]}
aria-label={placeholders[section]}
/>
<Button variant="outline" type="submit" disabled={busy}>
搜索
</Button>
</form>
{filters[section] && (
<select
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
aria-label="筛选状态"
>
<option value="">
{section === "tasks" ? "全部类别" : "全部状态"}
</option>
{filters[section]!.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
)}
{(query || status) && (
<Button
variant="ghost"
onClick={() => {
setDraft("");
setQuery("");
setStatus("");
setPage(1);
}}
>
重置
</Button>
)}
</div>
{error ? (
<div className="empty error-text" role="alert">
<Inbox size={30} />
<p>{error}</p>
<Button variant="outline" onClick={() => setRetry(retry + 1)}>
重新加载
</Button>
</div>
) : busy ? (
<div className="empty" role="status">
<LoaderCircle size={28} className="animate-spin" />
<p>正在加载{labels[section]}</p>
</div>
) : !data?.items.length ? (
<div className="empty">
<Inbox size={32} />
<h3>{query || status ? "没有符合条件的记录" : "这里还没有记录"}</h3>
<p>
{query || status
? "试试其他关键词或清除筛选条件。"
: "用户在小程序中的日常,会慢慢汇集到这里。"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{headings[section].map((heading, index) => (
<TableHead key={index}>{heading}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((row) =>
section === "users" ? (
<UserRow
key={`${revision}-${row.id}`}
row={row}
action={(row) => action("users", row)}
detail={detail}
disabled={actionBusy}
/>
) : (
<TableRow key={row.id}>
{section === "pets" ? (
<>
<TableCell>
<Identity
name={row.name}
id={row.id}
url={row.avatar_url || ""}
/>
</TableCell>
<TableCell>
{row.breed || "未填写品种"}
<small>
{row.gender === "male"
? "男孩"
: row.gender === "female"
? "女孩"
: "未填写"}
</small>
</TableCell>
<TableCell>
<Identity
name={row.user_name}
id={row.user_id}
person
/>
</TableCell>
<TableCell>
{row.weight_kg ? `${row.weight_kg} kg` : "—"}
</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : section === "walks" ? (
<>
<TableCell>
<Identity
name={row.user_name}
id={row.user_id}
person
/>
</TableCell>
<TableCell>
<div className="pet-tags">
{row.pet_names?.length
? row.pet_names.map((name, index) => (
<span key={index}>
<PawPrint size={12} />
{name}
</span>
))
: "—"}
</div>
<small className="mono" title={row.id}>
记录 {row.id.slice(0, 8)}
</small>
</TableCell>
<TableCell>{fmt(row.started_at)}</TableCell>
<TableCell>
{((row.distance_meters || 0) / 1000).toFixed(2)} km
<small>{duration(row.duration_seconds)}</small>
</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : section === "meetups" ? (
<>
<TableCell>
<strong>{row.title}</strong>
<small className="mono">{row.id.slice(0, 8)}</small>
</TableCell>
<TableCell>{row.user_name || "未设置昵称"}</TableCell>
<TableCell>
{row.place_name}
<small>{fmt(row.starts_at)}</small>
</TableCell>
<TableCell>
{row.participants} / {row.max_people}
</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : section === "tasks" ? (
<>
<TableCell>
<strong>{row.title}</strong>
<small>
{row.repeat_rule === "daily" ? "每天" : "单次"}
</small>
</TableCell>
<TableCell>
{row.user_name || "未设置昵称"}
<small>{row.pet_name || "—"}</small>
</TableCell>
<TableCell>
{categories[row.category || ""] || row.category || "—"}
</TableCell>
<TableCell>{row.time_of_day}</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : (
<>
<TableCell>
<strong className="file-name" title={row.name}>
{row.name}
</strong>
<small className="mono">{row.id.slice(0, 8)}</small>
</TableCell>
<TableCell>{row.content_type}</TableCell>
<TableCell>{bytes(row.size)}</TableCell>
<TableCell>{fmt(row.created_at)}</TableCell>
</>
)}
<TableCell>
<div className="row-actions">
<Button
variant="ghost"
size="sm"
onClick={() => detail(section, row)}
>
查看详情
</Button>
{section === "meetups" && row.status === "open" && (
<Button
variant="outline"
size="sm"
disabled={actionBusy}
onClick={() => action(section, row)}
>
取消活动
</Button>
)}
</div>
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
)}
<footer className="pagination">
<span>
{data?.total
? `第 ${(page - 1) * 10 + 1}–${Math.min(page * 10, data.total)} 条,共 ${data.total} 条`
: "共 0 条"}
</span>
<div>
<Button
variant="outline"
size="icon"
aria-label="上一页"
disabled={busy || page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft size={16} />
</Button>
<span>
第 {page} / {pages} 页
</span>
<Button
variant="outline"
size="icon"
aria-label="下一页"
disabled={busy || page >= pages}
onClick={() => setPage(page + 1)}
>
<ChevronRight size={16} />
</Button>
</div>
</footer>
</section>
);
}
@@ -0,0 +1,80 @@
import { PawPrint, UserRound } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import type { Row, Section } from "@/lib/api";
export function Avatar({
url,
person = false,
}: {
url?: string;
person?: boolean;
}) {
const [broken, setBroken] = useState("");
return (
<span className="avatar">
{url && broken !== url ? (
<img src={url} alt="" onError={() => setBroken(url)} />
) : person ? (
<UserRound size={20} />
) : (
<PawPrint size={20} />
)}
</span>
);
}
export function Status({ row, section }: { row: Row; section: Section }) {
const label =
section === "users"
? row.disabled
? "已停用"
: "正常"
: section === "pets"
? row.archived
? "已归档"
: "在用"
: section === "tasks"
? row.completed
? "今日已完成"
: "今日待完成"
: {
active: "遛狗中",
paused: "已暂停",
finished: "已结束",
open: "开放",
cancelled: "已取消",
}[row.status || ""] || "—";
const ended = row.disabled || row.archived || row.status === "cancelled";
return (
<Badge
variant={
ended ? "muted" : row.status === "paused" ? "warning" : "default"
}
>
{label}
</Badge>
);
}
export function Identity({
name,
id,
url,
person = false,
}: {
name?: string;
id?: string;
url?: string;
person?: boolean;
}) {
return (
<div className="identity">
{url !== undefined && <Avatar url={url} person={person} />}
<div>
<strong>{name || (person ? "未设置昵称" : "未命名")}</strong>
<small className="mono" title={id}>
{id || "—"}
</small>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import type { Section } from "@/lib/api";
export const labels: Record<Section | "overview", string> = {
overview: "工作台",
users: "用户管理",
pets: "宠物档案",
walks: "遛狗记录",
meetups: "约遛活动",
tasks: "日常任务",
files: "文件管理",
};
export const fmt = (value?: string) =>
value && !Number.isNaN(Date.parse(value))
? new Date(value).toLocaleString("zh-CN", { hour12: false })
: "—";
export const bytes = (value = 0) =>
value >= 1048576
? `${(value / 1048576).toFixed(1)} MB`
: `${(value / 1024).toFixed(1)} KB`;
export const duration = (value = 0) =>
`${Math.floor(value / 3600)}小时 ${Math.floor((value % 3600) / 60)}分钟`;
export const categories: Record<string, string> = {
food: "喂食",
water: "饮水",
walk: "运动",
medicine: "用药",
grooming: "清洁",
other: "其他",
};
export const filters: Partial<Record<Section, [string, string][]>> = {
users: [
["enabled", "正常"],
["disabled", "已停用"],
],
pets: [
["active", "在用"],
["archived", "已归档"],
],
walks: [
["active", "遛狗中"],
["paused", "已暂停"],
["finished", "已结束"],
],
meetups: [
["open", "开放"],
["cancelled", "已取消"],
],
tasks: Object.entries(categories),
};
export const placeholders: Record<Section, string> = {
users: "搜索用户昵称或 ID",
pets: "搜索宠物名字、品种或主人 ID",
walks: "搜索记录 ID 或用户 ID",
meetups: "搜索活动名称、地点或 ID",
tasks: "搜索任务名称、用户或宠物 ID",
files: "搜索文件名、MD5 或 ID",
};
@@ -40,4 +40,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)
Button.displayName = 'Button'
export { Button, buttonVariants }
export { Button }
+36 -15
View File
@@ -1,11 +1,11 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@@ -16,7 +16,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl',
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl",
className,
)}
{...props}
@@ -24,18 +24,39 @@ const DialogContent = React.forwardRef<
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100">
<X className="h-4 w-4" />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))
DialogContent.displayName = 'DialogContent'
));
DialogContent.displayName = "DialogContent";
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5', className)} {...props} />
function DialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex flex-col space-y-1.5", className)} {...props} />
);
}
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn('text-lg font-semibold', className)} {...props} />
function DialogTitle({
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle }
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
};
File diff suppressed because it is too large Load Diff
+113 -11
View File
@@ -1,9 +1,89 @@
import axios from "axios";
export type Section =
"users" | "pets" | "walks" | "meetups" | "tasks" | "files";
export interface Row {
id: string;
nickname?: string;
name?: string;
title?: string;
avatar_url?: string;
avatar_file_id?: string;
cover_url?: string;
user_id?: string;
user_name?: string;
host_user_id?: string;
pet_id?: string;
pet_name?: string;
pet_names?: string[];
pet_count?: number;
disabled?: boolean;
archived?: boolean;
status?: string;
created_at?: string;
started_at?: string;
ended_at?: string;
starts_at?: string;
breed?: string;
species?: string;
gender?: string;
birth_date?: string;
weight_kg?: number;
coat?: string;
allergies?: string;
care_notes?: string;
duration_seconds?: number;
distance_meters?: number;
share_enabled?: boolean;
place_name?: string;
description?: string;
latitude?: number;
longitude?: number;
participants?: number;
max_people?: number;
category?: string;
time_of_day?: string;
repeat_rule?: string;
completed?: boolean;
reminder_enabled?: boolean;
size?: number;
content_type?: string;
md5?: string;
url?: string;
bucket?: string;
object_key?: string;
}
export interface Point {
latitude: number;
longitude: number;
recorded_at: string;
}
export interface PageResult {
items: Row[];
total: number;
page: number;
page_size: number;
}
export interface Stats {
users: number;
pets: number;
walks: number;
meetups: number;
tasks: number;
files: number;
today_walks: number;
active_walks: number;
upcoming_meetups: number;
today_completions: number;
distance_meters: number;
duration_seconds: number;
storage_bytes: number;
daily_walks: { date: string; count: number }[];
}
const key = "pets_admin_token";
export const getToken = () => localStorage.getItem(key) || "";
export const setToken = (value: string) => localStorage.setItem(key, value);
export const clearToken = () => localStorage.removeItem(key);
const http = axios.create({ baseURL: "/api" });
const http = axios.create({ baseURL: "/api", timeout: 20000 });
http.interceptors.request.use((config) => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
@@ -16,20 +96,42 @@ http.interceptors.response.use(
throw new Error(body?.message || "请求失败");
},
(error) => {
if (error.response?.status === 401) clearToken();
throw new Error(error.response?.data?.message || error.message);
if (axios.isCancel(error)) return Promise.reject(error);
if (error.response?.status === 401) {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
return Promise.reject(
new Error(
error.response?.data?.message ||
(error.code === "ECONNABORTED"
? "请求超时,请重试"
: "无法连接服务器,请稍后重试"),
),
);
},
);
export const api = {
login: (username: string, password: string) =>
http.post<any, { token: string }>("/admin/login", { username, password }),
stats: () => http.get<any, Record<string, number>>("/admin/stats"),
users: () => http.get<any, any[]>("/admin/users"),
pets: () => http.get<any, any[]>("/admin/pets"),
walks: () => http.get<any, any[]>("/admin/walks"),
meetups: () => http.get<any, any[]>("/admin/meetups"),
http.post<unknown, { token: string }>("/admin/login", {
username,
password,
}),
stats: (signal?: AbortSignal) =>
http.get<unknown, Stats>("/admin/stats", { signal }),
list: (
section: Section,
params: { page: number; page_size: number; q: string; status: string },
signal?: AbortSignal,
) => http.get<unknown, PageResult>(`/admin/${section}`, { params, signal }),
userPets: (id: string, signal?: AbortSignal) =>
http.get<unknown, Row[]>(`/admin/users/${id}/pets`, { signal }),
walk: (id: string, signal?: AbortSignal) =>
http.get<unknown, { id: string; points: Point[] }>(`/admin/walks/${id}`, {
signal,
}),
setUserDisabled: (id: string, disabled: boolean) =>
http.put(`/admin/users/${id}/disabled`, { disabled }),
setMeetupStatus: (id: string, status: string) =>
http.put(`/admin/meetups/${id}/status`, { status }),
cancelMeetup: (id: string) =>
http.put(`/admin/meetups/${id}/status`, { status: "cancelled" }),
};