feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
+337 -47
View File
@@ -1,51 +1,341 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import type { JSX } from 'react'
import { getToken } from '@/lib/api'
import Layout from '@/components/Layout'
import Login from '@/pages/Login'
import Dashboard from '@/pages/Dashboard'
import Users from '@/pages/Users'
import Pets from '@/pages/Pets'
import Posts from '@/pages/Posts'
import Comments from '@/pages/Comments'
import Articles from '@/pages/Articles'
import CareTemplates from '@/pages/CareTemplates'
import RecordTypes from '@/pages/RecordTypes'
import Breeds from '@/pages/Breeds'
import CommunityBot from '@/pages/CommunityBot'
import Feedback from '@/pages/Feedback'
import Members from '@/pages/Members'
function RequireAuth({ children }: { children: JSX.Element }) {
return getToken() ? children : <Navigate to="/login" replace />
}
import { useEffect, useState, type FormEvent } from "react";
import {
Users,
PawPrint,
Route,
CalendarDays,
LogOut,
RefreshCw,
} 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: "约遛活动",
};
const icons = {
overview: PawPrint,
users: Users,
pets: PawPrint,
walks: Route,
meetups: CalendarDays,
};
const fmt = (v: string) => (v ? new Date(v).toLocaleString("zh-CN") : "—");
export default function App() {
const [token, auth] = 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 [error, setError] = useState(""),
[busy, setBusy] = useState(false);
async function load() {
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]);
async function login(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
const r = await api.login(username, password);
setToken(r.token);
auth(r.token);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function logout() {
clearToken();
auth("");
setPassword("");
}
async function toggle(row: any) {
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();
} catch (e) {
setError((e as Error).message);
}
}
if (!token)
return (
<main className="login-page">
<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">
登录管理后台
</Button>
</form>
</main>
);
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="pets" element={<Pets />} />
<Route path="posts" element={<Posts />} />
<Route path="comments" element={<Comments />} />
<Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} />
<Route path="record-types" element={<RecordTypes />} />
<Route path="breeds" element={<Breeds />} />
<Route path="community-bot" element={<CommunityBot />} />
<Route path="feedback" element={<Feedback />} />
<Route path="members" element={<Members />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<span>🐾</span>
<div>
<strong>肉垫计划</strong>
<small>ADMIN STUDIO</small>
</div>
</div>
<p className="side-caption">工作台</p>
<nav>
{(Object.keys(labels) as Tab[]).map((key) => {
const Icon = icons[key];
return (
<button
key={key}
className={tab === key ? "nav-item active" : "nav-item"}
onClick={() => setTab(key)}
>
<Icon size={18} />
{labels[key]}
</button>
);
})}
</nav>
<div className="side-bottom">
<span>让每次出门都值得记录</span>
<button onClick={logout}>
<LogOut size={16} /> 退出登录
</button>
</div>
</aside>
<main className="content">
<header className="topbar">
<div>
<p className="eyebrow">PAWPRINT DASHBOARD</p>
<h1>{labels[tab]}</h1>
</div>
<Button variant="outline" onClick={() => void load()} disabled={busy}>
<RefreshCw size={16} /> 刷新
</Button>
</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>
)}
</main>
</div>
);
}