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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+953
-205
File diff suppressed because it is too large
Load Diff
@@ -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" }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user