feat: add multi-source breed encyclopedia and two-hour refresh sessions
This commit is contained in:
Generated
+58
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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: "日常任务",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user