feat: add multi-source breed encyclopedia and two-hour refresh sessions
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-05 13:38:24 +08:00
parent 607d709100
commit 9d7cf720a5
73 changed files with 25771 additions and 264 deletions
+58
View File
@@ -17,6 +17,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
@@ -1600,6 +1601,19 @@
"node": ">= 6"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3076,6 +3090,44 @@
}
}
},
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -3282,6 +3334,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1
View File
@@ -19,6 +19,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
+28 -10
View File
@@ -1,5 +1,8 @@
import { NavLink, Navigate, useNavigate, useLocation, useParams } from "react-router-dom";
import Breeds from "@/components/admin/Breeds";
import { useEffect, useState, type FormEvent } from "react";
import {
Dog,
CalendarDays,
CheckCheck,
Files,
@@ -37,17 +40,21 @@ const icons = {
overview: LayoutDashboard,
users: Users,
pets: PawPrint,
breeds: Dog,
walks: Route,
meetups: CalendarDays,
tasks: CheckCheck,
files: Files,
};
type Tab = Section | "overview" | "breeds";
export default function App() {
const routerNavigate = useNavigate(), location = useLocation(), { section } = useParams();
const tab: Tab = section && section in labels ? section as Tab : "overview";
useEffect(() => { setDetail(null); setConfirm(null); setError(""); setNotice(""); setBusy(false); }, [location.pathname]);
const [token, setAuth] = useState(getToken()),
[username, setUsername] = useState("sundynix"),
[password, setPassword] = useState("");
const [tab, setTab] = useState<Section | "overview">("overview"),
[revision, refresh] = useState(0),
const [revision, refresh] = useState(0),
[stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState(""),
[busy, setBusy] = useState(false),
@@ -66,8 +73,10 @@ export default function App() {
setConfirm(null);
setError("登录已过期,请重新登录");
};
const sync = () => setAuth(getToken());
window.addEventListener("storage", sync);
window.addEventListener("admin-auth-expired", expired);
return () => window.removeEventListener("admin-auth-expired", expired);
return () => { window.removeEventListener("admin-auth-expired", expired); window.removeEventListener("storage", sync); };
}, []);
useEffect(() => {
if (!token || tab !== "overview") return;
@@ -94,7 +103,7 @@ export default function App() {
setError("");
try {
const value = await api.login(username.trim(), password);
setToken(value.token);
setToken(value);
setAuth(value.token);
setPassword("");
} catch (e) {
@@ -104,6 +113,7 @@ export default function App() {
}
}
function logout() {
void api.logout().catch(() => {});
clearToken();
setAuth("");
setPassword("");
@@ -113,9 +123,9 @@ export default function App() {
setError("");
setNotice("");
}
function navigate(section: Section | "overview") {
function navigate(section: Tab) {
setBusy(false);
setTab(section);
routerNavigate(`/admin/${section}`);
setError("");
setNotice("");
}
@@ -148,6 +158,12 @@ export default function App() {
? "启用用户"
: "停用用户"
: "取消约遛活动";
if (section !== "login" && !(section && Object.hasOwn(labels, section))) return <Navigate to="/admin/overview" replace />;
if (!token && section !== "login") return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
if (token && section === "login") {
const from = location.state?.from;
return <Navigate to={typeof from === "string" && /^\/admin\/(overview|users|pets|breeds|walks|meetups|tasks|files)$/.test(from) ? from : "/admin/overview"} replace />;
}
if (!token)
return (
<main className="login-page">
@@ -231,20 +247,20 @@ export default function App() {
</div>
<p className="side-caption">陪伴工作台</p>
<nav aria-label="管理导航">
{(Object.keys(labels) as (Section | "overview")[]).map((key) => {
{(Object.keys(labels) as Tab[]).map((key) => {
const Icon = icons[key];
return (
<button
<NavLink
to={`/admin/${key}`}
key={key}
className={`nav-item ${tab === key ? "active" : ""}`}
aria-current={tab === key ? "page" : undefined}
title={labels[key]}
onClick={() => navigate(key)}
>
<Icon size={19} />
<span>{labels[key]}</span>
{tab === key && <span className="nav-dot" />}
</button>
</NavLink>
);
})}
</nav>
@@ -318,6 +334,8 @@ export default function App() {
)}
{tab === "overview" ? (
<Overview stats={stats} navigate={navigate} />
) : tab === "breeds" ? (
<Breeds revision={revision} />
) : (
<Records
key={tab}
@@ -0,0 +1,660 @@
import { useEffect, useState, type FormEvent } from "react";
import {
ChevronLeft,
ChevronRight,
LoaderCircle,
Plus,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { api, type Breed, type BreedInput, type BreedPage } from "@/lib/api";
const empty: BreedInput = {
name: "",
english_name: "",
aliases: "",
enabled: true,
origin: "",
breed_group: "",
height: "",
weight: "",
summary: "",
temperament: "",
care_notes: "",
fci_number: "",
life_span: "",
exercise: "",
grooming: "",
traits: {},
};
const providerNames: Record<string, string> = {
cku: "CKU",
fci: "FCI",
akc: "AKC",
rkc: "英国皇家犬业俱乐部",
editorial: "编辑整理",
};
const factLabels: Record<string, string> = {
name: "中文名",
english_name: "英文名",
aliases: "别名",
origin: "起源地",
breed_group: "分组",
fci_number: "FCI 编号",
height: "肩高",
weight: "体重",
life_span: "寿命参考",
exercise: "运动参考",
grooming: "梳理参考",
temperament: "性格倾向",
traits: "指数",
size: "体型",
coat_length: "毛长",
sheds: "掉毛情况",
scope: "标准范围",
};
function parseTraits(value: string): Record<string, number> {
try {
return JSON.parse(value) || {};
} catch {
return {};
}
}
export default function Breeds({ revision }: { revision: number }) {
const [page, setPage] = useState(1),
[query, setQuery] = useState(""),
[draft, setDraft] = useState(""),
[initial, setInitial] = useState(""),
[status, setStatus] = useState("");
const [data, setData] = useState<BreedPage | null>(null),
[busy, setBusy] = useState(true),
[retry, setRetry] = useState(0),
[error, setError] = useState(""),
[notice, setNotice] = useState("");
const [sourceView, setSourceView] = useState(false);
const [editing, setEditing] = useState<Breed | "new" | null>(null),
[form, setForm] = useState<BreedInput>(empty),
[confirm, setConfirm] = useState<Breed | null>(null),
[saving, setSaving] = useState(false),
[writeError, setWriteError] = useState("");
useEffect(() => {
const controller = new AbortController();
setBusy(true);
setError("");
api
.breeds(
{ page, page_size: 10, q: query, status, initial },
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) return;
const last = Math.max(1, Math.ceil(result.total / 10));
if (page > last) {
setPage(last);
return;
}
setData(result);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [page, query, initial, status, revision, retry]);
function edit(row: Breed | "new") {
setEditing(row);
setSourceView(false);
setForm(
row === "new"
? { ...empty }
: {
name: row.name,
english_name: row.english_name,
aliases: row.aliases,
enabled: row.enabled,
origin: row.origin || "",
breed_group: row.breed_group || "",
height: row.height || "",
weight: row.weight || "",
summary: row.summary || "",
temperament: row.temperament || "",
care_notes: row.care_notes || "",
fci_number: row.fci_number || "",
life_span: row.life_span || "",
exercise: row.exercise || "",
grooming: row.grooming || "",
traits: parseTraits(row.traits),
},
);
setWriteError("");
}
async function save(e: FormEvent) {
e.preventDefault();
if (saving || !editing) return;
setSaving(true);
setWriteError("");
try {
await api.saveBreed(editing === "new" ? "" : editing.id, {
...form,
name: form.name.trim(),
});
setEditing(null);
setNotice("品种资料已保存");
setRetry((v) => v + 1);
} catch (e) {
setWriteError((e as Error).message);
} finally {
setSaving(false);
}
}
async function toggle() {
if (!confirm || saving) return;
setSaving(true);
setWriteError("");
try {
await api.setBreedEnabled(confirm.id, !confirm.enabled);
setNotice(
confirm.enabled ? "品种已停用,已有宠物档案保持原资料" : "品种已启用",
);
setConfirm(null);
setRetry((v) => v + 1);
} catch (e) {
setWriteError((e as Error).message);
} finally {
setSaving(false);
}
}
const pages = Math.max(1, Math.ceil((data?.total || 0) / 10));
return (
<section className="panel records">
<div className="records-heading">
<div>
<h2>狗狗品种</h2>
<p>
{data ? `共 ${data.total} 个品种` : "读取品种中"} · 按中文名称拼音
A–Z 排序
</p>
</div>
<Button onClick={() => edit("new")}>
<Plus size={16} />
新增品种
</Button>
</div>
{notice && (
<div className="notice" role="status">
{notice}
<Button variant="ghost" size="sm" onClick={() => setNotice("")}>
关闭
</Button>
</div>
)}
<div className="toolbar">
<form
className="search-form"
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setQuery(draft.trim());
setRetry((v) => v + 1);
}}
>
<Search size={17} />
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
maxLength={128}
placeholder="搜索品种、别名或拼音"
aria-label="搜索品种、别名或拼音"
/>
<Button variant="outline" disabled={busy}>
搜索
</Button>
</form>
<select
value={status}
aria-label="品种状态"
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="enabled">已启用</option>
<option value="disabled">已停用</option>
</select>
</div>
<div className="breed-alphabet" aria-label="拼音首字母筛选">
{["", ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "#"].map((letter) => (
<button
key={letter}
aria-pressed={initial === letter}
className={initial === letter ? "active" : ""}
onClick={() => {
setInitial(letter);
setPage(1);
}}
>
{letter || "全部"}
</button>
))}
</div>
{error ? (
<div className="empty error-text" role="alert">
<p>{error}</p>
<Button variant="outline" onClick={() => setRetry((v) => v + 1)}>
重新加载
</Button>
</div>
) : busy ? (
<div className="empty" role="status">
<LoaderCircle className="animate-spin" />
<p>正在加载品种</p>
</div>
) : !data?.items.length ? (
<div className="empty">
<p>没有符合条件的品种,可以新增补充。</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{["字母", "品种", "别名", "来源", "状态", "操作"].map((label) => (
<TableHead key={label}>{label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((row) => (
<TableRow key={row.id}>
<TableCell>
<span className="per-page">{row.initial}</span>
</TableCell>
<TableCell>
<strong>{row.name}</strong>
<small>{row.english_name || row.pinyin}</small>
<small>{row.summary ? "已整理百科" : "百科待补充"}</small>
</TableCell>
<TableCell className="breed-aliases">
{row.aliases || "—"}
</TableCell>
<TableCell>
{row.references?.length ? (
<div className="breed-source-badges">
{row.references.map((ref) => (
<a
key={ref.id}
href={ref.url}
target="_blank"
rel="noreferrer"
className="per-page"
>
{providerNames[ref.provider] || ref.provider}
</a>
))}
</div>
) : row.source_url ? (
<a
href={row.source_url}
target="_blank"
rel="noreferrer"
className="text-action"
>
{row.source}
</a>
) : (
row.source
)}
</TableCell>
<TableCell>
<span className="per-page">
{row.enabled ? "已启用" : "已停用"}
</span>
</TableCell>
<TableCell>
<div className="row-actions">
<Button size="sm" variant="ghost" onClick={() => edit(row)}>
查看 / 编辑
</Button>
<Button
size="sm"
variant="outline"
disabled={saving}
onClick={() => {
setConfirm(row);
setWriteError("");
}}
>
{row.enabled ? "停用" : "启用"}
</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 条"}{" "}
· 每页 10 条
</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>
<Dialog
open={!!editing}
onOpenChange={(open) => {
if (!open && !saving) setEditing(null);
}}
>
<DialogContent className="breed-dialog" aria-describedby="breed-help">
<DialogHeader>
<DialogTitle>
{editing === "new" ? "新增品种" : "编辑品种"}
</DialogTitle>
</DialogHeader>
<p id="breed-help" className="muted">
拼音和 A–Z 分组自动生成。别名用于搜索,例如“边牧”。
</p>
<div className="breed-editor-tabs">
<Button
type="button"
variant={!sourceView ? "default" : "outline"}
onClick={() => setSourceView(false)}
>
展示资料
</Button>
<Button
type="button"
variant={sourceView ? "default" : "outline"}
disabled={editing === "new"}
onClick={() => setSourceView(true)}
>
官方来源 ·{" "}
{editing && editing !== "new"
? editing.references?.length || 0
: 0}
</Button>
</div>
{sourceView && editing && editing !== "new" ? (
<div className="breed-source-list">
<p className="muted">
各机构的分类、标准与指数单独保留。展示资料由编辑整理,保存后后续导入会保留人工修改。
</p>
{editing.references?.length ? (
editing.references.map((ref) => (
<article key={ref.id} className="breed-source-card">
<header>
<strong>
{providerNames[ref.provider] || ref.provider}
</strong>
<a
href={ref.url}
target="_blank"
rel="noreferrer"
className="text-action"
>
查看原始资料
</a>
</header>
<small>
采集于 {ref.retrieved_at} · {ref.external_id}
</small>
<dl>
{Object.entries(ref.facts).map(([key, value]) => (
<div key={key}>
<dt>{factLabels[key] || key}</dt>
<dd>
{key === "traits"
? Object.entries(parseTraits(value))
.map(
([label, score]) => `${label} ${score}/5`,
)
.join(" · ")
: value}
</dd>
</div>
))}
</dl>
</article>
))
) : (
<p className="muted">这是手动录入的犬种,暂未关联官方资料。</p>
)}
</div>
) : (
<form onSubmit={save} className="breed-form">
<label>
中文名称 *
<Input
value={form.name}
required
maxLength={128}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<label>
英文名称(选填)
<Input
value={form.english_name}
maxLength={128}
onChange={(e) =>
setForm({ ...form, english_name: e.target.value })
}
/>
</label>
<label>
别名(选填,多个用逗号分隔)
<Input
value={form.aliases}
maxLength={512}
onChange={(e) =>
setForm({ ...form, aliases: e.target.value })
}
/>
</label>
<div className="breed-fact-fields">
{(
[
["origin", "起源地"],
["breed_group", "FCI 分组"],
["fci_number", "FCI 编号"],
["life_span", "寿命参考"],
["height", "参考肩高(含单位)"],
["weight", "参考体重(含单位)"],
["exercise", "运动参考"],
["grooming", "毛发护理"],
] as const
).map(([key, label]) => (
<label key={key}>
{label}
<Input
value={form[key]}
maxLength={
["breed_group", "exercise", "grooming"].includes(key)
? 255
: key === "fci_number"
? 16
: 128
}
onChange={(e) =>
setForm({ ...form, [key]: e.target.value })
}
/>
</label>
))}
</div>
<label>
简短介绍
<textarea
value={form.summary}
maxLength={2000}
rows={3}
onChange={(e) =>
setForm({ ...form, summary: e.target.value })
}
/>
</label>
<label>
性格倾向
<Input
value={form.temperament}
maxLength={255}
onChange={(e) =>
setForm({ ...form, temperament: e.target.value })
}
/>
</label>
<div className="breed-fact-fields">
{[
"运动需求",
"运动活力",
"训练接受度",
"掉毛程度",
"吠叫倾向",
"梳毛需求",
"亲近家人",
].map((label) => (
<label key={label}>
{label}
<select
value={form.traits?.[label] || ""}
onChange={(e) => {
const traits = { ...form.traits };
if (e.target.value)
traits[label] = Number(e.target.value);
else delete traits[label];
setForm({ ...form, traits });
}}
>
<option value="">暂无指数</option>
{[1, 2, 3, 4, 5].map((score) => (
<option key={score} value={score}>
{score} / 5
</option>
))}
</select>
</label>
))}
</div>
<label>
相处与照顾笔记
<textarea
value={form.care_notes}
maxLength={2000}
rows={3}
onChange={(e) =>
setForm({ ...form, care_notes: e.target.value })
}
/>
</label>
<label className="breed-enabled">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) =>
setForm({ ...form, enabled: e.target.checked })
}
/>
在小程序品种列表中显示
</label>
{writeError && (
<p className="error-banner" role="alert">
{writeError}
</p>
)}
<div className="dialog-actions">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => setEditing(null)}
>
取消
</Button>
<Button disabled={saving}>
{saving && (
<LoaderCircle size={16} className="animate-spin" />
)}
保存品种
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
<Dialog
open={!!confirm}
onOpenChange={(open) => {
if (!open && !saving) setConfirm(null);
}}
>
<DialogContent aria-describedby="breed-toggle-help">
<DialogHeader>
<DialogTitle>{confirm?.enabled ? "停用" : "启用"}品种</DialogTitle>
</DialogHeader>
<p id="breed-toggle-help">
确认{confirm?.enabled ? "停用" : "启用"}「{confirm?.name}
」?停用后小程序不再提供此选项,已有宠物档案保留。
</p>
{writeError && (
<p className="error-banner" role="alert">
{writeError}
</p>
)}
<div className="dialog-actions">
<Button
variant="outline"
disabled={saving}
onClick={() => setConfirm(null)}
>
取消
</Button>
<Button disabled={saving} onClick={() => void toggle()}>
确认
</Button>
</div>
</DialogContent>
</Dialog>
</section>
);
}
@@ -1,8 +1,9 @@
import type { Section } from "@/lib/api";
export const labels: Record<Section | "overview", string> = {
export const labels: Record<Section | "overview" | "breeds", string> = {
overview: "工作台",
users: "用户管理",
pets: "宠物档案",
breeds: "狗狗品种",
walks: "遛狗记录",
meetups: "约遛活动",
tasks: "日常任务",
+25
View File
@@ -1090,3 +1090,28 @@ td strong {
padding-right: 20px;
}
}
.nav-item { text-decoration: none; }
.breed-alphabet { display:flex; flex-wrap:wrap; gap:6px; padding:0 24px 24px; }
.breed-alphabet button { min-width:30px; height:30px; border-radius:9px; border:1px solid #eeded1; padding:0 8px; color:#927766; }
.breed-alphabet button.active { background:#ffeadc; border-color:#dfa080; color:#b85837; }
.breed-aliases { max-width:240px; white-space:normal; }
.breed-form { display:grid; gap:18px; }
.breed-form label { display:grid; gap:8px; }
.breed-form .breed-enabled { display:flex; align-items:center; gap:10px; }
.breed-dialog {max-height:85vh;overflow-y:auto;}
.breed-fact-fields {display:grid;grid-template-columns:1fr 1fr;gap:16px;}
.breed-form textarea {width:100%;border:1px solid #eadacb;border-radius:12px;padding:12px;resize:vertical;}
.breed-source-badges{display:flex;flex-wrap:wrap;gap:6px;max-width:280px}
.breed-source-badges a{text-decoration:none}
.breed-editor-tabs{display:flex;gap:10px;margin-bottom:8px}
.breed-source-list{display:grid;gap:16px}
.breed-source-card{border:1px solid #ead9cc;background:#fffbf7;padding:18px;border-radius:16px}
.breed-source-card header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.breed-source-card small{display:block;margin-top:8px;color:#a28a78}
.breed-source-card dl{display:grid;gap:12px;margin-top:18px}
.breed-source-card dl>div{display:grid;grid-template-columns:90px 1fr;gap:12px}
.breed-source-card dt{color:#a28a78;font-size:13px}
.breed-source-card dd{margin:0;font-size:14px;overflow-wrap:anywhere}
.breed-form select{width:100%;min-height:40px;border:1px solid #ead9cc;border-radius:10px;padding:8px;background:white}
+201 -10
View File
@@ -1,6 +1,11 @@
import axios from "axios";
export type Section =
"users" | "pets" | "walks" | "meetups" | "tasks" | "files";
| "users"
| "pets"
| "walks"
| "meetups"
| "tasks"
| "files";
export interface Row {
id: string;
nickname?: string;
@@ -52,6 +57,64 @@ export interface Row {
bucket?: string;
object_key?: string;
}
export interface BreedReference {
id: string;
provider: string;
external_id: string;
url: string;
retrieved_at: string;
facts: Record<string, string>;
}
export interface Breed {
id: string;
name: string;
english_name: string;
aliases: string;
fci_number: string;
life_span: string;
exercise: string;
grooming: string;
traits: string;
references?: BreedReference[];
field_sources?: Record<string, string>;
editorial_locked: boolean;
initial: string;
pinyin: string;
enabled: boolean;
source: string;
source_url: string;
origin: string;
breed_group: string;
height: string;
weight: string;
summary: string;
temperament: string;
care_notes: string;
}
export type BreedInput = Pick<
Breed,
| "name"
| "english_name"
| "aliases"
| "enabled"
| "origin"
| "breed_group"
| "height"
| "weight"
| "summary"
| "temperament"
| "care_notes"
| "fci_number"
| "life_span"
| "exercise"
| "grooming"
> & { traits?: Record<string, number> };
export interface BreedPage {
items: Breed[];
total: number;
page: number;
page_size: number;
}
export interface Point {
latitude: number;
longitude: number;
@@ -79,13 +142,101 @@ export interface Stats {
storage_bytes: number;
daily_walks: { date: string; count: number }[];
}
const key = "pets_admin_token";
export interface Session {
token: string;
refresh_token: string;
expires_at: number;
refresh_expires_at: number;
}
const key = "pets_admin_token",
refreshKey = "pets_admin_refresh_token",
expiryKey = "pets_admin_expires_at";
export const getToken = () => localStorage.getItem(key) || "";
export const setToken = (value: string) => localStorage.setItem(key, value);
export const clearToken = () => localStorage.removeItem(key);
export const setToken = (value: Session) => {
localStorage.setItem(refreshKey, value.refresh_token);
localStorage.setItem(expiryKey, String(value.expires_at));
localStorage.setItem(key, value.token);
};
export const clearToken = () => {
localStorage.removeItem(key);
localStorage.removeItem(refreshKey);
localStorage.removeItem(expiryKey);
};
const raw = axios.create({ baseURL: "/api", timeout: 20000 });
const http = axios.create({ baseURL: "/api", timeout: 20000 });
http.interceptors.request.use((config) => {
const token = getToken();
let refreshing: Promise<string> | null = null;
function expireSession(usedRefresh: string) {
if (localStorage.getItem(refreshKey) !== usedRefresh) return;
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
async function performRefresh(): Promise<string> {
const used = localStorage.getItem(refreshKey) || "";
if (!used) {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
throw new Error("登录已过期,请重新登录");
}
try {
const response = await raw.post("/admin/refresh", { refresh_token: used });
if (response.data?.code !== 0 || !response.data.data?.refresh_token)
throw new Error(response.data?.message || "刷新登录失败");
// Logout while the request is in flight must not resurrect the session.
if (localStorage.getItem(refreshKey) !== used) {
if (getToken()) return getToken();
throw new Error("已退出登录");
}
setToken(response.data.data);
return response.data.data.token;
} catch (error) {
if (
axios.isAxiosError(error) &&
[401, 403].includes(error.response?.status || 0)
) {
if (
localStorage.getItem(refreshKey) &&
localStorage.getItem(refreshKey) !== used
)
return getToken();
expireSession(used);
}
throw new Error(
axios.isAxiosError(error)
? error.response?.data?.message || "暂时无法刷新登录,请重试"
: (error as Error).message,
);
}
}
function refreshSession(usedAccess?: string): Promise<string> {
if (refreshing) return refreshing;
const work = () => {
if (usedAccess && getToken() !== usedAccess && getToken())
return Promise.resolve(getToken());
const expires = Number(localStorage.getItem(expiryKey));
if (!usedAccess && expires > Date.now() / 1000 + 60 && getToken())
return Promise.resolve(getToken());
return performRefresh();
};
// Serialize browser tabs as well as requests in this tab when Web Locks are available.
refreshing = (
navigator.locks
? navigator.locks.request("pets-admin-refresh", work)
: work()
).finally(() => {
refreshing = null;
});
return refreshing;
}
http.interceptors.request.use(async (config) => {
let token = getToken();
const expires = Number(localStorage.getItem(expiryKey));
if (
token &&
config.url !== "/admin/login" &&
expires &&
expires <= Date.now() / 1000 + 60
)
token = await refreshSession();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
@@ -95,9 +246,27 @@ http.interceptors.response.use(
if (body?.code === 0) return body.data;
throw new Error(body?.message || "请求失败");
},
(error) => {
async (error) => {
if (axios.isCancel(error)) return Promise.reject(error);
if (error.response?.status === 401) {
const config = error.config as
| (import("axios").InternalAxiosRequestConfig & { retried?: boolean })
| undefined;
if (
error.response?.status === 401 &&
config &&
config.url !== "/admin/login" &&
!config.retried
) {
config.retried = true;
const usedAccess = String(config.headers.Authorization || "").replace(
/^Bearer /,
"",
);
const next = await refreshSession(usedAccess);
config.headers.Authorization = `Bearer ${next}`;
return http.request(config);
}
if (error.response?.status === 401 && config?.url !== "/admin/login") {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
@@ -106,14 +275,36 @@ http.interceptors.response.use(
error.response?.data?.message ||
(error.code === "ECONNABORTED"
? "请求超时,请重试"
: "无法连接服务器,请稍后重试"),
: error.message || "无法连接服务器,请稍后重试"),
),
);
},
);
export const api = {
logout: () => {
const refresh_token = localStorage.getItem(refreshKey);
return refresh_token
? raw.post("/admin/logout", { refresh_token })
: Promise.resolve();
},
breeds: (
params: {
page: number;
page_size: number;
q: string;
status: string;
initial: string;
},
signal?: AbortSignal,
) => http.get<unknown, BreedPage>("/admin/breeds", { params, signal }),
saveBreed: (id: string, data: BreedInput) =>
id
? http.put(`/admin/breeds/${id}`, data)
: http.post("/admin/breeds", data),
setBreedEnabled: (id: string, enabled: boolean) =>
http.put(`/admin/breeds/${id}/enabled`, { enabled }),
login: (username: string, password: string) =>
http.post<unknown, { token: string }>("/admin/login", {
http.post<unknown, Session>("/admin/login", {
username,
password,
}),
+7 -1
View File
@@ -2,9 +2,15 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter><Routes>
<Route path="/" element={<Navigate to="/admin/overview" replace />} />
<Route path="/admin" element={<Navigate to="/admin/overview" replace />} />
<Route path="/admin/:section" element={<App />} />
<Route path="*" element={<Navigate to="/admin/overview" replace />} />
</Routes></BrowserRouter>
</StrictMode>,
)