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
+3 -30
View File
@@ -1,32 +1,5 @@
# React + TypeScript + Vite
# 管理端
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
React + Vite + Tailwind + shadcn/ui。`npm ci && npm run build` 会把产物输出到 `../../internal/admin/dist`,由 Go 服务嵌入并提供 `/admin/`。
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
开发时执行 `npm run dev`,Vite 会把 `/api` 代理到本地 9090 端口。
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+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>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,66 +0,0 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, ListChecks, Dna, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react'
import { api, clearToken } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const nav = [
{ to: '/', label: '概览', icon: LayoutDashboard, end: true },
{ to: '/users', label: '用户管理', icon: Users },
{ to: '/pets', label: '宠物管理', icon: PawPrint },
{ to: '/posts', label: '帖子审核', icon: MessageSquare },
{ to: '/comments', label: '评论管理', icon: MessagesSquare },
{ to: '/articles', label: '文章管理', icon: FileText },
{ to: '/care-templates', label: '养护模板', icon: ClipboardList },
{ to: '/record-types', label: '记录事项', icon: ListChecks },
{ to: '/breeds', label: '品种管理', icon: Dna },
{ to: '/community-bot', label: '社区运营', icon: Sparkles },
{ to: '/feedback', label: '意见反馈', icon: MessageCircle },
{ to: '/members', label: '会员管理', icon: Crown },
]
export default function Layout() {
const navigate = useNavigate()
function logout() {
// 服务端作废刷新令牌,成不成都要本地清掉
api.logout().catch(() => {})
clearToken()
navigate('/login')
}
return (
<div className="flex min-h-screen">
<aside className="w-56 shrink-0 border-r bg-card p-4 flex flex-col">
<div className="flex items-center gap-2 px-2 py-3 mb-4">
<span className="text-2xl">🐾</span>
<span className="font-bold">毛孩子后台</span>
</div>
<nav className="flex flex-col gap-1">
{nav.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:bg-accent',
)
}
>
<n.icon className="h-4 w-4" />
{n.label}
</NavLink>
))}
</nav>
<div className="mt-auto">
<Button variant="ghost" className="w-full justify-start text-muted-foreground" onClick={logout}>
<LogOut className="h-4 w-4" /> 退出登录
</Button>
</div>
</aside>
<main className="flex-1 p-8 overflow-auto">
<Outlet />
</main>
</div>
)
}
@@ -1,30 +0,0 @@
import { Button } from '@/components/ui/button'
export function Pager({
page,
totalPages,
total,
onChange,
}: {
page: number
totalPages: number
total: number
onChange: (p: number) => void
}) {
return (
<div className="flex items-center justify-between pt-4 text-sm text-muted-foreground">
<span>共 {total} 条</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
上一页
</Button>
<span>
{page} / {totalPages}
</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onChange(page + 1)}>
下一页
</Button>
</div>
</div>
)
}
+333 -30
View File
@@ -1,37 +1,340 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 40 33% 98%;
--foreground: 24 10% 15%;
--card: 0 0% 100%;
--card-foreground: 24 10% 15%;
--primary: 33 90% 62%;
--primary-foreground: 0 0% 100%;
--secondary: 40 30% 94%;
--secondary-foreground: 24 10% 25%;
--muted: 40 20% 94%;
--muted-foreground: 25 8% 45%;
--accent: 40 40% 92%;
--accent-foreground: 24 10% 20%;
--destructive: 5 78% 57%;
--destructive-foreground: 0 0% 100%;
--border: 33 25% 88%;
--input: 33 25% 88%;
--ring: 33 90% 62%;
--radius: 0.6rem;
:root {
--background: 150 18% 97%;
--foreground: 166 48% 18%;
--card: 0 0% 100%;
--card-foreground: 166 48% 18%;
--primary: 166 58% 21%;
--primary-foreground: 0 0% 100%;
--secondary: 145 22% 92%;
--secondary-foreground: 166 48% 18%;
--muted: 145 12% 94%;
--muted-foreground: 150 9% 51%;
--accent: 145 22% 92%;
--accent-foreground: 166 48% 18%;
--destructive: 5 70% 56%;
--destructive-foreground: 0 0% 100%;
--border: 145 16% 86%;
--input: 145 16% 86%;
--ring: 166 58% 21%;
--radius: 0.8rem;
}
* {
border-color: hsl(var(--border));
}
body {
margin: 0;
background: #f6f8f5;
color: #193f37;
font-family:
-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei",
sans-serif;
}
.app-shell {
min-height: 100vh;
display: flex;
}
.sidebar {
width: 242px;
flex: none;
background: #194d41;
color: #fff;
padding: 30px 18px;
display: flex;
flex-direction: column;
}
.brand {
display: flex;
gap: 12px;
align-items: center;
padding: 5px 12px 38px;
}
.brand > span {
font-size: 28px;
background: #e9aa6d;
border-radius: 16px;
padding: 8px;
}
.brand strong {
display: block;
font-size: 19px;
}
.brand small {
display: block;
font-size: 10px;
letter-spacing: 0.22em;
opacity: 0.56;
margin-top: 2px;
}
.side-caption {
font-size: 11px;
color: #9abcb0;
padding: 0 14px;
letter-spacing: 0.2em;
}
.sidebar nav {
display: grid;
gap: 7px;
}
.nav-item {
border: 0;
background: transparent;
color: #c2d9cf;
display: flex;
align-items: center;
gap: 13px;
text-align: left;
padding: 13px 15px;
border-radius: 13px;
cursor: pointer;
}
.nav-item:hover,
.nav-item.active {
background: #336a5c;
color: #fff;
}
.side-bottom {
margin-top: auto;
color: #8ab5a4;
font-size: 12px;
padding: 12px;
}
.side-bottom button {
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: #d4e6dc;
margin-top: 18px;
cursor: pointer;
}
.content {
width: 100%;
max-width: 1500px;
padding: 40px 48px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
color: #74a28b;
margin: 0 0 8px;
}
.topbar h1 {
font-size: 30px;
margin: 0;
}
.hero {
background: #194d41;
border-radius: 27px;
color: white;
min-height: 196px;
padding: 34px 40px;
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
}
.hero p {
color: #a9d7bc;
margin: 0 0 10px;
}
.hero h2 {
font-size: 27px;
margin: 0 0 12px;
}
.hero span {
color: #c9e0d3;
}
.hero-paw {
font-size: 90px;
background: #edbe8b;
border-radius: 50%;
width: 152px;
height: 152px;
text-align: center;
line-height: 152px;
transform: rotate(-9deg);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 22px;
}
.stat-card,
.table-card {
background: #fff;
border: 1px solid #e7ede7;
box-shadow: 0 12px 40px #214d3f0a;
border-radius: 23px;
}
.stat-card {
padding: 23px;
}
.stat-icon {
font-size: 27px;
margin-bottom: 16px;
}
.stat-card span {
display: block;
color: #82988e;
font-size: 13px;
}
.stat-card strong {
font-size: 34px;
}
.table-head {
padding: 26px 28px 16px;
}
.table-head h2 {
margin: 0;
font-size: 20px;
}
.table-head p {
color: #87998f;
font-size: 13px;
}
.table-scroll {
overflow: auto;
}
table {
border-collapse: collapse;
width: 100%;
min-width: 800px;
}
th,
td {
text-align: left;
padding: 16px 24px;
border-top: 1px solid #edf0ec;
white-space: nowrap;
font-size: 13px;
}
th {
color: #81978b;
font-weight: 600;
background: #fbfcfa;
}
td b {
display: block;
}
td small {
display: block;
color: #9aada2;
font-size: 11px;
margin-top: 3px;
}
.mono {
font-size: 11px;
color: #71877d;
}
.pill {
padding: 5px 9px;
border-radius: 99px;
font-size: 11px;
}
.pill.green {
background: #e8f5ec;
color: #258052;
}
.pill.red {
background: #fff0eb;
color: #ba6b51;
}
.empty {
text-align: center;
padding: 55px;
color: #91a397;
}
.error-banner,
.error {
color: #a64d37;
background: #fff0eb;
border-radius: 12px;
padding: 11px;
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at 75% 10%, #e9f1e7, transparent 35%), #f6f8f5;
}
.login-card {
width: min(420px, calc(100% - 40px));
background: white;
border-radius: 28px;
padding: 40px;
box-shadow: 0 25px 70px #204a3a18;
}
.login-mark {
width: 60px;
height: 60px;
background: #e9aa6d;
border-radius: 19px;
display: grid;
place-items: center;
font-size: 30px;
margin-bottom: 22px;
}
.login-card h1 {
font-size: 30px;
margin: 0;
}
.muted {
color: #90a198;
margin: 8px 0 30px;
}
.login-card label {
display: grid;
gap: 7px;
margin-bottom: 18px;
color: #456a5b;
font-size: 13px;
}
.login-card input {
margin-top: 2px;
}
.login-card button {
margin-top: 8px;
}
@media (max-width: 800px) {
.sidebar {
width: 70px;
padding: 20px 8px;
}
* {
border-color: hsl(var(--border));
.brand div,
.side-caption,
.nav-item:not(.active),
.nav-item.active {
font-size: 0;
}
body {
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Microsoft YaHei', sans-serif;
.brand {
padding: 5px 5px 30px;
}
.nav-item {
justify-content: center;
}
.side-bottom {
display: none;
}
.content {
padding: 28px 18px;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.hero-paw {
display: none;
}
}
+30 -162
View File
@@ -1,167 +1,35 @@
import axios from 'axios'
const TOKEN_KEY = 'pets_admin_token'
const REFRESH_KEY = 'pets_admin_refresh'
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_KEY) || ''
}
export function setToken(t: string, refresh?: string) {
localStorage.setItem(TOKEN_KEY, t)
if (refresh) localStorage.setItem(REFRESH_KEY, refresh)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
}
const http = axios.create({ baseURL: '/api' })
http.interceptors.request.use((cfg) => {
const t = getToken()
if (t) cfg.headers.Authorization = `Bearer ${t}`
return cfg
})
// access token 只有 2 小时,过期后拿 refresh token 换新的。
// 用单飞(single-flight)保证并发请求只触发一次刷新,否则刷新令牌会被并发轮换掉。
let refreshing: Promise<string> | null = null
function refreshAccessToken(): Promise<string> {
if (!refreshing) {
const rt = getRefreshToken()
refreshing = (
rt
? axios.post('/api/admin/refresh', { refresh_token: rt }).then((r) => {
const body = r.data
if (!body || body.code !== 0) throw new Error(body?.message || '续期失败')
setToken(body.data.token, body.data.refresh_token)
return body.data.token as string
})
: Promise.reject(new Error('没有刷新令牌'))
).finally(() => {
refreshing = null
})
}
return refreshing
}
function logout() {
clearToken()
if (location.pathname !== '/admin/login') location.href = '/admin/login'
}
// 这几个接口本身就可能返回 40100(密码错、刷新令牌失效),不能再触发刷新,否则会绕圈
const NO_REFRESH = ['/admin/login', '/admin/refresh', '/admin/logout']
// 统一响应 {code,message,data}:成功返回 data,失败抛错
import axios from "axios";
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" });
http.interceptors.request.use((config) => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
http.interceptors.response.use(
async (resp) => {
const body = resp.data
if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 0) return body.data
if (body.code === 40100) {
const cfg = resp.config as typeof resp.config & { _retried?: boolean }
const skip = NO_REFRESH.some((p) => (cfg.url || '').startsWith(p))
if (!skip && !cfg._retried) {
cfg._retried = true
try {
const t = await refreshAccessToken()
cfg.headers.Authorization = `Bearer ${t}`
return await http.request(cfg)
} catch {
/* 续期失败,落到下面退出登录 */
}
}
if (!skip) logout()
}
return Promise.reject(new Error(body.message || '请求失败'))
}
return body
(response) => {
const body = response.data;
if (body?.code === 0) return body.data;
throw new Error(body?.message || "请求失败");
},
(err) => Promise.reject(err),
)
export interface Page<T> {
list: T[]
total: number
page: number
page_size: number
}
(error) => {
if (error.response?.status === 401) clearToken();
throw new Error(error.response?.data?.message || error.message);
},
);
export const api = {
login: (username: string, password: string) =>
http.post<any, { token: string; refresh_token: string; expires_in: number; admin: any }>(
'/admin/login',
{ username, password },
),
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
me: () => http.get<any, any>('/admin/me'),
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
analytics: (days = 30) => http.get<any, any>('/admin/analytics', { params: { days } }),
users: (params: any) => http.get<any, Page<any>>('/admin/users', { params }),
setUserDisabled: (id: number, disabled: boolean) =>
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"),
setUserDisabled: (id: string, disabled: boolean) =>
http.put(`/admin/users/${id}/disabled`, { disabled }),
pets: (params: any) => http.get<any, Page<any>>('/admin/pets', { params }),
posts: (params: any) => http.get<any, Page<any>>('/admin/posts', { params }),
setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }),
comments: (params: any) => http.get<any, Page<any>>('/admin/comments', { params }),
setCommentStatus: (id: number, status: string) => http.put(`/admin/comments/${id}/status`, { status }),
deleteComment: (id: number) => http.delete(`/admin/comments/${id}`),
articles: (params: any) => http.get<any, Page<any>>('/admin/articles', { params }),
saveArticle: (a: any) => http.post('/admin/articles', a),
deleteArticle: (id: number) => http.delete(`/admin/articles/${id}`),
memberships: (params: any) => http.get<any, Page<any>>('/admin/memberships', { params }),
careTemplateMeta: () => http.get<any, any>('/admin/care-templates/meta'),
// 方案改成「同一个物种阶段可以多套」,所以按 id 存取,不再按 species+stage 覆盖
careTemplates: () => http.get<any, any[]>('/admin/care-templates'),
careTemplate: (id: string) => http.get<any, any>(`/admin/care-templates/${id}`),
saveCareTemplate: (t: any) => http.post<any, any>('/admin/care-templates', t),
deleteCareTemplate: (id: string) => http.delete(`/admin/care-templates/${id}`),
genCareTemplate: (species: string, stage: string) =>
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
species,
stage,
}),
feedback: (params: any) => http.get<any, Page<any>>('/admin/feedback', { params }),
setFeedbackHandled: (id: string, handled: boolean) =>
http.put(`/admin/feedback/${id}/handled`, { handled }),
aiQuota: () =>
http.get<any, { enabled: boolean; daily_chat: number; daily_symptom: number; daily_plan: number }>(
'/admin/ai-quota',
),
saveAIQuota: (c: any) => http.put<any, any>('/admin/ai-quota', c),
// 记录类型(小程序「记一笔」的那些事项)
recordTypes: () => http.get<any, any[]>('/admin/record-types'),
saveRecordType: (t: any) => http.post('/admin/record-types', t),
deleteRecordType: (id: string) => http.delete(`/admin/record-types/${id}`),
// 品种
breeds: (species?: string) =>
http.get<any, any[]>('/admin/breeds', { params: species ? { species } : {} }),
saveBreed: (b: any) => http.post('/admin/breeds', b),
deleteBreed: (id: string) => http.delete(`/admin/breeds/${id}`),
communityBot: () =>
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
'/admin/community-bot',
),
saveCommunityBot: (c: any) => http.put<any, any>('/admin/community-bot', c),
genCommunityPosts: (count: number) =>
http.post<any, { made: number }>('/admin/community-bot/generate', { count }),
}
export default http
setMeetupStatus: (id: string, status: string) =>
http.put(`/admin/meetups/${id}/status`, { status }),
};
-33
View File
@@ -1,33 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import type { Page } from '@/lib/api'
export function usePaged<T>(
fetcher: (params: Record<string, any>) => Promise<Page<T>>,
extra: Record<string, any> = {},
) {
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [data, setData] = useState<Page<T> | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const extraKey = JSON.stringify(extra)
const load = useCallback(() => {
setLoading(true)
setError('')
fetcher({ page, page_size: pageSize, ...extra })
.then(setData)
.catch((e) => setError(e.message || '加载失败'))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, extraKey])
useEffect(() => {
load()
}, [load])
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1
return { page, setPage, pageSize, data, loading, error, reload: load, totalPages }
}
-147
View File
@@ -1,147 +0,0 @@
import { useState } from 'react'
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Pager } from '@/components/Pager'
const empty = {
id: 0, icon: '📘', title: '', description: '', content: '',
category: '', related_sheet_type: '', published: true,
}
export default function Articles() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.articles)
const [open, setOpen] = useState(false)
const [form, setForm] = useState<any>(empty)
function edit(a: any) {
setForm({ ...a })
setOpen(true)
}
function create() {
setForm({ ...empty })
setOpen(true)
}
async function save() {
await api.saveArticle(form)
setOpen(false)
reload()
}
async function del(id: number) {
await api.deleteArticle(id)
reload()
}
const set = (k: string) => (e: any) => setForm({ ...form, [k]: e.target.value })
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">文章管理</h1>
<Button onClick={create}>新建文章</Button>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>标题</TableHead>
<TableHead>分类</TableHead>
<TableHead>关联弹层</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((a) => (
<TableRow key={a.id}>
<TableCell>{a.id}</TableCell>
<TableCell className="font-medium">
<span className="mr-1">{a.icon}</span>
{a.title}
</TableCell>
<TableCell>{a.category || '—'}</TableCell>
<TableCell>{a.related_sheet_type || '—'}</TableCell>
<TableCell>
{a.published ? <Badge variant="success">已发布</Badge> : <Badge variant="muted">草稿</Badge>}
</TableCell>
<TableCell className="text-right space-x-2 whitespace-nowrap">
<Button variant="outline" size="sm" onClick={() => edit(a)}>
编辑
</Button>
<Button variant="destructive" size="sm" onClick={() => del(a.id)}>
删除
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{form.id ? '编辑文章' : '新建文章'}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>图标(emoji)</Label>
<Input value={form.icon} onChange={set('icon')} />
</div>
<div className="space-y-1.5">
<Label>分类</Label>
<Input value={form.category} onChange={set('category')} />
</div>
<div className="space-y-1.5 col-span-2">
<Label>标题</Label>
<Input value={form.title} onChange={set('title')} />
</div>
<div className="space-y-1.5 col-span-2">
<Label>简介</Label>
<Input value={form.description} onChange={set('description')} />
</div>
<div className="space-y-1.5">
<Label>关联弹层类型</Label>
<Input value={form.related_sheet_type} onChange={set('related_sheet_type')} />
</div>
<div className="space-y-1.5">
<Label>发布状态</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-card px-3 text-sm"
value={form.published ? '1' : '0'}
onChange={(e) => setForm({ ...form, published: e.target.value === '1' })}
>
<option value="1">已发布</option>
<option value="0">草稿</option>
</select>
</div>
<div className="space-y-1.5 col-span-2">
<Label>正文</Label>
<textarea
className="flex min-h-24 w-full rounded-md border border-input bg-card px-3 py-2 text-sm"
value={form.content}
onChange={set('content')}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button onClick={save}>保存</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
-227
View File
@@ -1,227 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
type Breed = {
id: string
species: string
name: string
initial: string
sort: number
enabled: boolean
}
const empty: Breed = { id: '', species: 'cat', name: '', initial: '', sort: 0, enabled: true }
const SPECIES = [
{ key: 'cat', label: '猫' },
{ key: 'dog', label: '狗' },
]
export default function Breeds() {
const [species, setSpecies] = useState('cat')
const [list, setList] = useState<Breed[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Breed>(empty)
const [err, setErr] = useState('')
function reload() {
api
.breeds(species)
.then((r) => setList(r || []))
.catch(() => setList([]))
}
useEffect(reload, [species])
function create() {
setForm({ ...empty, species })
setErr('')
setOpen(true)
}
function edit(b: Breed) {
setForm({ ...b })
setErr('')
setOpen(true)
}
async function save() {
setErr('')
try {
await api.saveBreed(form)
setOpen(false)
reload()
} catch (e: any) {
// 后端会明确说是重名还是首字母不合法,直接透出来比一句「保存失败」有用
setErr(e?.message || '保存失败')
}
}
async function del(b: Breed) {
if (!confirm(`删除「${b.name}」?`)) return
try {
await api.deleteBreed(b.id)
reload()
} catch (e: any) {
alert(e?.message || '删除失败')
}
}
const set = (k: keyof Breed) => (e: any) => setForm({ ...form, [k]: e.target.value })
// 按首字母分段展示,和小程序端的 A-Z 索引对得上
const groups: Record<string, Breed[]> = {}
list.forEach((b) => {
const k = (b.initial || '#').toUpperCase()
;(groups[k] = groups[k] || []).push(b)
})
const letters = Object.keys(groups).sort()
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">品种管理</h1>
<p className="mt-1 text-sm text-muted-foreground">
建档第二步的品种选择器读这里。首字母要手填 —— 多音字(藏獒、柴犬)自动转拼音常出错。
</p>
</div>
<Button onClick={create}>新增品种</Button>
</div>
<div className="mb-4 flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
variant={species === s.key ? 'default' : 'outline'}
size="sm"
onClick={() => setSpecies(s.key)}
>
{s.label}
</Button>
))}
<span className="ml-2 self-center text-sm text-muted-foreground">
共 {list.length} 种,{letters.length} 个首字母
</span>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">首字母</TableHead>
<TableHead>品种</TableHead>
<TableHead className="w-20">排序</TableHead>
<TableHead className="w-20">状态</TableHead>
<TableHead className="w-32 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{letters.map((L) =>
groups[L].map((b, i) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-muted-foreground">
{i === 0 ? L : ''}
</TableCell>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell className="text-muted-foreground">{b.sort}</TableCell>
<TableCell>
{b.enabled ? (
<Badge>启用</Badge>
) : (
<Badge variant="secondary">停用</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(b)}>
编辑
</Button>
<Button variant="ghost" size="sm" onClick={() => del(b)}>
删除
</Button>
</TableCell>
</TableRow>
)),
)}
{!list.length && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
还没有品种
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{form.id ? '编辑品种' : '新增品种'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label>物种</Label>
<div className="flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
type="button"
variant={form.species === s.key ? 'default' : 'outline'}
size="sm"
onClick={() => setForm({ ...form, species: s.key })}
>
{s.label}
</Button>
))}
</div>
</div>
<div className="grid gap-2">
<Label>品种名</Label>
<Input value={form.name} onChange={set('name')} placeholder="例如:布偶猫" />
</div>
<div className="grid gap-2">
<Label>首字母(A-Z,一位)</Label>
<Input
value={form.initial}
maxLength={1}
onChange={(e) => setForm({ ...form, initial: e.target.value.toUpperCase() })}
placeholder="B"
className="w-20 font-mono"
/>
<p className="text-xs text-muted-foreground">
按国内常用读法填。多音字自己定:藏獒填 Z,柴犬填 C。
</p>
</div>
<div className="grid gap-2">
<Label>排序(同首字母内,小的在前)</Label>
<Input
type="number"
value={form.sort}
onChange={(e) => setForm({ ...form, sort: Number(e.target.value) || 0 })}
className="w-28"
/>
</div>
<div className="flex items-center gap-2">
<input
id="bd-enabled"
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<Label htmlFor="bd-enabled" className="cursor-pointer">
启用(停用后建档时选不到,已填这个品种的档案不受影响)
</Label>
</div>
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save}>保存</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
@@ -1,523 +0,0 @@
import { useEffect, useState } from 'react'
import { Cat, Dog, Plus, Trash2, Wand2, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
const SPECIES = [
{ key: 'cat', label: '猫猫', Icon: Cat },
{ key: 'dog', label: '狗狗', Icon: Dog },
]
// 阶段列表从后端 meta 来(service.CareStages)。写死在这里的话,
// 后端从 3 段细分成 5 段之后这一页就少两个选项,而且不会报错
const REMINDER_LABEL: Record<string, string> = {
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报',
}
const REMINDER_TYPES = ['vaccine', 'deworm', 'weight', 'monthlyReport']
const REPEATS = [
{ key: 'daily', label: '每天' },
{ key: 'weekly', label: '每周' },
{ key: 'monthly', label: '每月' },
{ key: 'once', label: '一次性' },
]
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
// 阶段的展示顺序。后端 CareStages 就是这个顺序,但列表里要用它排序,
// 单独列一份免得依赖接口返回的时序
const STAGE_ORDER = ['刚到家 0-30 天', '幼年期', '青年期', '成年期', '中年期', '老年期']
const selectCls = 'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0'
type Item = {
_k: number
kind: 'plan' | 'reminder'
title: string
description: string
priority: string
sheet_type: string
repeat: string
day: number
weekdays: string
month_day: number
reminder_type: string
offset_days: number
frequency: string
}
type Tpl = {
id: string
species: string
stage: string
name: string
summary: string
sort: number
enabled: boolean
items?: any[]
}
let uid = 1
function toItem(r: any): Item {
return {
_k: uid++,
kind: r.kind === 'reminder' ? 'reminder' : 'plan',
title: r.title || '',
description: r.description || '',
priority: r.priority || '',
sheet_type: r.sheet_type || '',
repeat: r.repeat || 'once',
day: r.day ?? 0,
weekdays: r.weekdays || '',
month_day: r.month_day ?? 1,
reminder_type: r.reminder_type || 'vaccine',
offset_days: r.offset_days ?? 0,
frequency: r.frequency || '',
}
}
function blank(kind: Item['kind']): Item {
return toItem({ kind, repeat: kind === 'plan' ? 'daily' : '' })
}
export default function CareTemplates() {
const [list, setList] = useState<Tpl[]>([])
const [tab, setTab] = useState('cat')
const [types, setTypes] = useState<any[]>([])
const [stages, setStages] = useState<string[]>([])
const [open, setOpen] = useState(false)
const [tpl, setTpl] = useState<Tpl>({
id: '', species: 'cat', stage: '成年期', name: '', summary: '', sort: 0, enabled: true,
})
const [items, setItems] = useState<Item[]>([])
const [err, setErr] = useState('')
const [saving, setSaving] = useState(false)
const [gen, setGen] = useState(false)
function reload() {
api.careTemplates().then((r) => setList(r || [])).catch(() => setList([]))
}
useEffect(() => {
reload()
// 「关联记录事项」的可选值来自 record_types,不再是写死的几个。
// 原来那两个硬编码数组里还有 report / taskDetail 这种早就不存在的值
api
.careTemplateMeta()
.then((m: any) => {
setTypes(m?.record_types || [])
setStages(m?.stages || [])
})
.catch(() => {})
}, [])
function create() {
// 默认跟当前 tab 的物种,省一步点击
setTpl({ id: '', species: tab, stage: '成年期', name: '', summary: '', sort: 0, enabled: true })
setItems([])
setErr('')
setOpen(true)
}
async function edit(t: Tpl) {
setErr('')
const full = await api.careTemplate(t.id)
setTpl({ ...t })
setItems(((full as any)?.items || []).map(toItem))
setOpen(true)
}
async function save() {
setErr('')
setSaving(true)
try {
await api.saveCareTemplate({ ...tpl, items })
setOpen(false)
reload()
} catch (e: any) {
// 后端会指名道姓说哪条配错了(「XX 选了每周,但没勾星期几」)
setErr(e?.message || '保存失败')
} finally {
setSaving(false)
}
}
async function del(t: Tpl) {
if (!confirm(`删除方案「${t.name || t.stage}」?已经套用出去的计划不受影响。`)) return
await api.deleteCareTemplate(t.id).catch((e: any) => alert(e?.message || '删除失败'))
reload()
}
async function draft() {
setGen(true)
try {
const r: any = await api.genCareTemplate(tpl.species, tpl.stage)
setItems((r?.items || []).map(toItem))
} catch (e: any) {
setErr(e?.message || 'AI 生成失败')
} finally {
setGen(false)
}
}
const upd = (k: number, field: string, v: any) =>
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [field]: v } : it)))
const rm = (k: number) => setItems((prev) => prev.filter((it) => it._k !== k))
function toggleWd(it: Item, idx: number) {
const n = String(idx + 1)
const cur = it.weekdays.split(',').filter(Boolean)
const next = cur.includes(n) ? cur.filter((x) => x !== n) : [...cur, n].sort()
upd(it._k, 'weekdays', next.join(','))
}
// 按 tab 过滤,并按阶段顺序排(数据库按 sort 排,但 sort 是全局的,
// 分物种看时会跳号)
const order = STAGE_ORDER
const shown = list
.filter((t) => t.species === tab)
.sort((a, b) => order.indexOf(a.stage) - order.indexOf(b.stage))
const plans = items.filter((i) => i.kind === 'plan')
const reminders = items.filter((i) => i.kind === 'reminder')
// 列表里展示「这套方案套一个月大概会排多少条」——只报明细条数会骗人:
// 4 条「每天」套一整月就是 120 多条
function spread(t: Tpl) {
const its = t.items || []
const c = { daily: 0, weekly: 0, monthly: 0, once: 0, rem: 0 }
its.forEach((i: any) => {
if (i.kind === 'reminder') c.rem++
else if (i.repeat === 'daily') c.daily++
else if (i.repeat === 'weekly') c.weekly++
else if (i.repeat === 'monthly') c.monthly++
else c.once++
})
return c
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">养护方案</h1>
<p className="mt-1 text-sm text-muted-foreground">
用户建档最后一步从这里挑一套,也可以选「先空着自己排」。同一个物种阶段可以配多套。
</p>
</div>
<Button onClick={create}>
<Plus className="h-4 w-4" /> 新建方案
</Button>
</div>
{/* 猫狗分开看。混在一起 10 套要上下找,而且阶段名一样(幼年期/青年期…),
扫一眼分不清哪套是猫的 */}
<div className="mb-4 flex items-center gap-2">
{SPECIES.map((s) => {
const n = list.filter((t) => t.species === s.key).length
return (
<Button
key={s.key}
size="sm"
variant={tab === s.key ? 'default' : 'outline'}
onClick={() => setTab(s.key)}
>
<s.Icon className="h-4 w-4" /> {s.label} {n}
</Button>
)
})}
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">阶段</TableHead>
<TableHead>方案名</TableHead>
<TableHead>内容</TableHead>
<TableHead className="w-20">状态</TableHead>
<TableHead className="w-32 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shown.map((t) => {
const c = spread(t)
return (
<TableRow key={t.id}>
<TableCell className="text-muted-foreground">{t.stage}</TableCell>
<TableCell>
<div className="font-medium">{t.name || '(未命名)'}</div>
<div className="text-xs text-muted-foreground">{t.summary}</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
每天 {c.daily} · 每周 {c.weekly} · 每月 {c.monthly} · 一次性 {c.once} · 提醒 {c.rem}
</TableCell>
<TableCell>
{t.enabled ? <Badge>启用</Badge> : <Badge variant="secondary">停用</Badge>}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(t)}>
编辑
</Button>
<Button variant="ghost" size="sm" onClick={() => del(t)}>
删除
</Button>
</TableCell>
</TableRow>
)
})}
{!shown.length && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
这个物种还没有方案 —— 用户建档后计划页会是空的,只能自己排
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[88vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{tpl.id ? '编辑方案' : '新建方案'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>物种</Label>
<div className="flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
type="button"
size="sm"
variant={tpl.species === s.key ? 'default' : 'outline'}
onClick={() => setTpl({ ...tpl, species: s.key })}
>
<s.Icon className="h-4 w-4" /> {s.label}
</Button>
))}
</div>
</div>
<div className="grid gap-2">
<Label>阶段</Label>
<select
className={selectCls}
value={tpl.stage}
onChange={(e) => setTpl({ ...tpl, stage: e.target.value })}
>
{stages.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-2">
<Label>方案名(用户建档时看到的就是这个)</Label>
<Input
value={tpl.name}
onChange={(e) => setTpl({ ...tpl, name: e.target.value })}
placeholder="幼猫标准照护"
/>
</div>
<div className="grid gap-2">
<Label>一句话摘要</Label>
<Input
value={tpl.summary}
onChange={(e) => setTpl({ ...tpl, summary: e.target.value })}
placeholder="每天清砂盆和喂食,每周称体重,含疫苗驱虫提醒"
/>
</div>
<div className="flex items-center gap-2">
<input
id="ct-enabled"
type="checkbox"
checked={tpl.enabled}
onChange={(e) => setTpl({ ...tpl, enabled: e.target.checked })}
/>
<Label htmlFor="ct-enabled" className="cursor-pointer">
启用(停用后建档时挑不到)
</Label>
</div>
{/* ── 计划事项 ── */}
<div className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">计划事项({plans.length})</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={draft} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 起草'}
</Button>
<Button size="sm" variant="outline" onClick={() => setItems([...items, blank('plan')])}>
<Plus className="h-4 w-4" /> 加一条
</Button>
</div>
</div>
<div className="mb-3 flex items-start gap-2 rounded bg-muted p-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
「关联记录事项」= 用户在小程序点这条计划时,直接跳到哪个记录表单。
比如「记录一次体重」关联体重,点了就是体重表单,不用自己去记录页找。
这个下拉读的是记录事项那一页的配置。
</span>
</div>
<div className="grid gap-2">
{plans.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input
className="min-w-40 flex-1"
placeholder="做什么"
value={it.title}
onChange={(e) => upd(it._k, 'title', e.target.value)}
/>
<Input
className="min-w-40 flex-1"
placeholder="说明"
value={it.description}
onChange={(e) => upd(it._k, 'description', e.target.value)}
/>
<select
className={selectCls}
value={it.repeat}
onChange={(e) => upd(it._k, 'repeat', e.target.value)}
>
{REPEATS.map((r) => (
<option key={r.key} value={r.key}>
{r.label}
</option>
))}
</select>
{it.repeat === 'weekly' && (
<div className="flex gap-1">
{WEEKDAYS.map((w, i) => (
<button
key={w}
type="button"
onClick={() => toggleWd(it, i)}
className={
'h-7 w-7 rounded text-xs ' +
(it.weekdays.split(',').includes(String(i + 1))
? 'bg-primary text-primary-foreground'
: 'border border-input bg-card')
}
>
{w}
</button>
))}
</div>
)}
{it.repeat === 'monthly' && (
<Input
type="number"
className="w-20"
placeholder="几号"
value={it.month_day}
onChange={(e) => upd(it._k, 'month_day', Number(e.target.value) || 1)}
/>
)}
{it.repeat === 'once' && (
<Input
type="number"
className="w-24"
placeholder="第N天"
value={it.day}
onChange={(e) => upd(it._k, 'day', Number(e.target.value) || 0)}
/>
)}
<select
className={selectCls}
value={it.priority}
onChange={(e) => upd(it._k, 'priority', e.target.value)}
>
<option value="">普通</option>
<option value="重要">重要</option>
</select>
<select
className={selectCls}
value={it.sheet_type}
onChange={(e) => upd(it._k, 'sheet_type', e.target.value)}
>
<option value="">不关联</option>
{types.map((t: any) => (
<option key={t.code} value={t.code}>
{t.label}
</option>
))}
</select>
<Button variant="ghost" size="sm" onClick={() => rm(it._k)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{!plans.length && (
<p className="py-4 text-center text-sm text-muted-foreground">还没有计划事项</p>
)}
</div>
</div>
{/* ── 提醒 ── */}
<div className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">到期提醒({reminders.length})</div>
<Button size="sm" variant="outline" onClick={() => setItems([...items, blank('reminder')])}>
<Plus className="h-4 w-4" /> 加一条
</Button>
</div>
<p className="mb-3 text-xs text-muted-foreground">
提醒不占计划页的格子,只在到期时冒出来。疫苗和驱虫这两类建档时就会自动建
(漏针是真会出事的),不用等用户挑方案。
</p>
<div className="grid gap-2">
{reminders.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<select
className={selectCls}
value={it.reminder_type}
onChange={(e) => upd(it._k, 'reminder_type', e.target.value)}
>
{REMINDER_TYPES.map((r) => (
<option key={r} value={r}>
{REMINDER_LABEL[r]}
</option>
))}
</select>
<Input
className="min-w-40 flex-1"
placeholder="提醒标题"
value={it.title}
onChange={(e) => upd(it._k, 'title', e.target.value)}
/>
<Input
type="number"
className="w-28"
placeholder="N天后到期"
value={it.offset_days}
onChange={(e) => upd(it._k, 'offset_days', Number(e.target.value) || 0)}
/>
<Input
className="w-40"
placeholder="频率说明"
value={it.frequency}
onChange={(e) => upd(it._k, 'frequency', e.target.value)}
/>
<Button variant="ghost" size="sm" onClick={() => rm(it._k)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{!reminders.length && (
<p className="py-4 text-center text-sm text-muted-foreground">还没有提醒</p>
)}
</div>
</div>
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存方案'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
-126
View File
@@ -1,126 +0,0 @@
import { useState } from 'react'
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
const statusMap: Record<string, { label: string; variant: any }> = {
pending: { label: '待审核', variant: 'warning' },
published: { label: '正常', variant: 'success' },
rejected: { label: '已打回', variant: 'destructive' },
deleted: { label: '已删除', variant: 'destructive' },
}
const FILTERS = [
{ label: '待审核', status: 'pending' },
{ label: '正常', status: 'published' },
{ label: '全部', status: '' },
]
function isUrl(s: string) {
return typeof s === 'string' && s.indexOf('http') === 0
}
export default function Comments() {
const [status, setStatus] = useState('pending')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments, { status })
async function setStat(id: number, s: string) {
await api.setCommentStatus(id, s)
reload()
}
async function del(id: number) {
await api.deleteComment(id)
reload()
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">评论审核</h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{FILTERS.map((f) => (
<button
key={f.status}
onClick={() => { setStatus(f.status); setPage(1) }}
className={`rounded-md px-3 py-1 text-sm transition ${
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>作者</TableHead>
<TableHead>内容</TableHead>
<TableHead>配图</TableHead>
<TableHead>帖子</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((c) => {
const imgs: string[] = Array.isArray(c.images) ? c.images.filter(isUrl) : []
return (
<TableRow key={c.id}>
<TableCell className="whitespace-nowrap align-top">{c.author_name}</TableCell>
<TableCell className="max-w-xs align-top">
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{c.content || '—'}</div>
</TableCell>
<TableCell className="align-top">
{imgs.length ? (
<div className="flex flex-wrap gap-1" style={{ maxWidth: 180 }}>
{imgs.map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer">
<img src={u} className="h-14 w-14 rounded-md border object-cover" loading="lazy" />
</a>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">无</span>
)}
</TableCell>
<TableCell className="align-top text-xs text-muted-foreground">{c.post_id}</TableCell>
<TableCell className="align-top">
<Badge variant={statusMap[c.status]?.variant}>{statusMap[c.status]?.label ?? c.status}</Badge>
</TableCell>
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
{c.status !== 'published' && c.status !== 'deleted' && (
<Button variant="default" size="sm" onClick={() => setStat(c.id, 'published')}>
通过
</Button>
)}
{c.status !== 'deleted' && (
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
删除
</Button>
)}
</TableCell>
</TableRow>
)
})}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
这个状态下暂无评论
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}
@@ -1,219 +0,0 @@
import { useEffect, useState } from 'react'
import { Sparkles, Save, Wand2, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
type Cfg = { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }
type Quota = { enabled: boolean; daily_chat: number; daily_symptom: number; daily_plan: number }
export default function CommunityBot() {
const [cfg, setCfg] = useState<Cfg>({ enabled: true, daily_count: 3, start_hour: 9, end_hour: 21 })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [genN, setGenN] = useState(5)
const [gen, setGen] = useState(false)
const [msg, setMsg] = useState('')
// AI 额度和社区机器人都是「烧多少 token」的开关,放一页里管
const [q, setQ] = useState<Quota>({ enabled: true, daily_chat: 20, daily_symptom: 10, daily_plan: 5 })
const [qSaving, setQSaving] = useState(false)
const [qMsg, setQMsg] = useState('')
useEffect(() => {
api.communityBot().then((c) => {
setCfg(c)
setLoading(false)
})
api.aiQuota().then(setQ).catch(() => {})
}, [])
const qNum = (k: keyof Quota) => (e: any) => {
let v = Number(e.target.value)
if (Number.isNaN(v) || v < 0) v = 0
setQ({ ...q, [k]: Math.min(999, v) })
}
async function saveQuota() {
setQSaving(true)
setQMsg('')
try {
setQ(await api.saveAIQuota(q))
setQMsg('已保存,立即生效')
} catch (e: any) {
setQMsg(e.message || '保存失败')
} finally {
setQSaving(false)
}
}
const num = (k: keyof Cfg, min: number, max: number) => (e: any) => {
let v = Number(e.target.value)
if (Number.isNaN(v)) v = min
v = Math.max(min, Math.min(max, v))
setCfg({ ...cfg, [k]: v })
}
async function save() {
setSaving(true)
setMsg('')
try {
const saved = await api.saveCommunityBot(cfg)
setCfg(saved)
setMsg('已保存')
} catch (e: any) {
setMsg(e?.message || '保存失败')
} finally {
setSaving(false)
}
}
async function generate() {
setGen(true)
setMsg('')
try {
const { made } = await api.genCommunityPosts(genN)
setMsg(`已生成 ${made} 条并发布到宠友圈`)
} catch (e: any) {
setMsg(e?.message || '生成失败')
} finally {
setGen(false)
}
}
return (
<div className="max-w-2xl">
<div className="mb-4">
<h1 className="text-2xl font-bold">社区运营</h1>
<p className="text-sm text-muted-foreground mt-1">
AI 以虚拟宠友账号自动生成养宠动态丰富社区,每条都会带「AI」标签,可在「帖子审核」里删除或隐藏。
</p>
</div>
{msg && (
<div className="mb-4 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-sm text-primary">
<Info className="h-4 w-4" /> {msg}
</div>
)}
{loading ? (
<p className="text-sm text-muted-foreground">加载中…</p>
) : (
<div className="space-y-4">
<Card>
<CardContent className="pt-5 pb-5 space-y-4">
<div className="flex items-center gap-2 font-medium">
<Sparkles className="h-4 w-4 text-primary" /> 每日自动生成
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>自动生成</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-card px-3 text-sm"
value={cfg.enabled ? '1' : '0'}
onChange={(e) => setCfg({ ...cfg, enabled: e.target.value === '1' })}
>
<option value="1">开启</option>
<option value="0">关闭</option>
</select>
</div>
<div className="space-y-1.5">
<Label>每天条数</Label>
<Input type="number" value={cfg.daily_count} onChange={num('daily_count', 0, 50)} />
</div>
<div className="space-y-1.5">
<Label>投放起始时段(点)</Label>
<Input type="number" value={cfg.start_hour} onChange={num('start_hour', 0, 23)} />
</div>
<div className="space-y-1.5">
<Label>投放结束时段(点)</Label>
<Input type="number" value={cfg.end_hour} onChange={num('end_hour', 1, 24)} />
</div>
</div>
<p className="text-xs text-muted-foreground">
当前设置:{cfg.enabled ? '开启' : '关闭'},每天 {cfg.daily_count} 条,
在 {cfg.start_hour}:00 – {cfg.end_hour}:00 之间随机时间发布。
</p>
<div>
<Button onClick={save} disabled={saving}>
<Save className="h-4 w-4" /> {saving ? '保存中…' : '保存设置'}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5 space-y-4">
<div className="flex items-center gap-2 font-medium">
<Wand2 className="h-4 w-4 text-primary" /> 立即生成一批
</div>
<p className="text-sm text-muted-foreground">
社区太空时可一次性铺一批。会立刻调用 AI 生成并发布,最多 30 条。
</p>
<div className="flex items-end gap-3">
<div className="space-y-1.5">
<Label>条数</Label>
<Input
type="number"
className="w-28"
value={genN}
onChange={(e) => setGenN(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
/>
</div>
<Button variant="outline" onClick={generate} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : `立即生成 ${genN} 条`}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
<Card className="mt-4">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
<span className="font-medium">AI 每日次数上限</span>
</div>
<p className="text-sm text-muted-foreground flex gap-2">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>
按「每个用户 / 每个自然日」计,改完立即生效。填 0 表示该功能不限次。
总开关关掉则全部不限——AI 调用是真金白银,关之前想清楚。
</span>
</p>
<div className="flex items-center gap-2">
<input
id="q-enabled"
type="checkbox"
checked={q.enabled}
onChange={(e) => setQ({ ...q, enabled: e.target.checked })}
/>
<Label htmlFor="q-enabled">启用次数限制</Label>
</div>
<div className="grid grid-cols-3 gap-4 max-w-lg">
<div className="space-y-1.5">
<Label>问问 AI</Label>
<Input type="number" value={q.daily_chat} onChange={qNum('daily_chat')} />
</div>
<div className="space-y-1.5">
<Label>异常评估</Label>
<Input type="number" value={q.daily_symptom} onChange={qNum('daily_symptom')} />
</div>
<div className="space-y-1.5">
<Label>AI 生成计划</Label>
<Input type="number" value={q.daily_plan} onChange={qNum('daily_plan')} />
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={saveQuota} disabled={qSaving}>
<Save className="h-4 w-4" /> {qSaving ? '保存中…' : '保存额度'}
</Button>
{qMsg && <span className="text-sm text-muted-foreground">{qMsg}</span>}
</div>
</CardContent>
</Card>
</div>
)
}
-187
View File
@@ -1,187 +0,0 @@
import { useEffect, useState } from 'react'
import { Users, PawPrint, MessageSquare, Activity, TrendingUp, CalendarDays, UserPlus, Repeat } from 'lucide-react'
import {
ResponsiveContainer, AreaChart, Area, BarChart, Bar, LineChart, Line,
XAxis, YAxis, CartesianGrid, Tooltip, Cell,
} from 'recharts'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
const ORANGE = '#F5A93E'
const GREEN = '#3FB984'
const BLUE = '#5B9BD5'
type Pt = { date: string; count: number }
type Ret = { day: number; rate: number; base: number }
type Analytics = {
summary: {
total_users: number; new_today: number; new_month: number
dau: number; wau: number; mau: number
total_pets: number; total_posts: number; total_records: number
}
new_trend: Pt[]; active_trend: Pt[]; mau_trend: Pt[]; retention: Ret[]
}
const RANGES = [
{ label: '近 7 天', days: 7 },
{ label: '近 30 天', days: 30 },
{ label: '近 90 天', days: 90 },
]
export default function Dashboard() {
const [days, setDays] = useState(30)
const [a, setA] = useState<Analytics | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
api.analytics(days).then((d) => { setA(d); setLoading(false) }).catch(() => setLoading(false))
}, [days])
const s = a?.summary
const kpis = [
{ label: '真实用户', value: s?.total_users, icon: Users, tint: ORANGE },
{ label: '日活 DAU', value: s?.dau, icon: Activity, tint: GREEN },
{ label: '周活 WAU', value: s?.wau, icon: CalendarDays, tint: BLUE },
{ label: '月活 MAU', value: s?.mau, icon: TrendingUp, tint: ORANGE },
{ label: '今日新增', value: s?.new_today, icon: UserPlus, tint: GREEN },
{ label: '本月新增', value: s?.new_month, icon: UserPlus, tint: BLUE },
]
const mini = [
{ label: '宠物', value: s?.total_pets, icon: PawPrint },
{ label: '帖子', value: s?.total_posts, icon: MessageSquare },
{ label: '健康记录', value: s?.total_records, icon: Activity },
]
const retData = (a?.retention || []).map((r) => ({ ...r, pct: Math.round(r.rate * 1000) / 10 }))
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">概览</h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{RANGES.map((r) => (
<button
key={r.days}
onClick={() => setDays(r.days)}
className={`rounded-md px-3 py-1 text-sm transition ${
days === r.days ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* KPI 卡 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
{kpis.map((c) => (
<Card key={c.label}>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
<c.icon className="h-4 w-4" style={{ color: c.tint }} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{c.value ?? '—'}</div>
</CardContent>
</Card>
))}
</div>
{/* 图表区 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ChartCard title="新增趋势" hint={`每日新增真实用户 · ${days} 天`}>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={a?.new_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<defs>
<linearGradient id="gNew" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={ORANGE} stopOpacity={0.35} />
<stop offset="100%" stopColor={ORANGE} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Area type="monotone" dataKey="count" name="新增" stroke={ORANGE} strokeWidth={2} fill="url(#gNew)" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="活跃趋势" hint={`每日活跃用户 DAU · ${days} 天`}>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={a?.active_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Line type="monotone" dataKey="count" name="活跃" stroke={GREEN} strokeWidth={2.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="月活 MAU" hint="近 6 个月去重活跃用户">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={a?.mau_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Bar dataKey="count" name="月活" fill={BLUE} radius={[6, 6, 0, 0]} maxBarSize={44} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="留存曲线" hint="注册后第 N 天仍活跃的比例(滚动口径)">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={retData} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="day" tick={{ fontSize: 11 }} tickFormatter={(d) => `D${d}`} />
<YAxis tick={{ fontSize: 11 }} width={40} unit="%" domain={[0, 100]} />
<Tooltip
contentStyle={tipProps.contentStyle}
formatter={(v: any, _n: any, p: any) => [`${v}%(分母 ${p.payload.base})`, '留存率']}
labelFormatter={(d) => `注册后第 ${d} 天`}
/>
<Line type="monotone" dataKey="pct" name="留存率" stroke={ORANGE} strokeWidth={2.5}>
{retData.map((_, i) => <Cell key={i} />)}
</Line>
</LineChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* 底部小计数 */}
<div className="grid grid-cols-3 gap-4">
{mini.map((c) => (
<Card key={c.label}>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
<c.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent><div className="text-2xl font-bold">{c.value ?? '—'}</div></CardContent>
</Card>
))}
</div>
{loading && <div className="text-center text-sm text-muted-foreground">加载中…</div>}
</div>
)
}
const tipProps = {
contentStyle: { borderRadius: 10, border: '1px solid #eee', fontSize: 12 },
cursor: { fill: 'rgba(245,169,62,.08)' },
}
function ChartCard({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{title}</CardTitle>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
-69
View File
@@ -1,69 +0,0 @@
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
export default function Feedback() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.feedback)
async function toggle(f: any) {
await api.setFeedbackHandled(f.id, !f.handled)
reload()
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold">意见反馈</h1>
<p className="text-sm text-muted-foreground mt-1">来自小程序「我的 → 意见反馈」的用户留言。</p>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>时间</TableHead>
<TableHead>用户</TableHead>
<TableHead>内容</TableHead>
<TableHead>联系方式</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((f) => (
<TableRow key={f.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">
{String(f.created_at).slice(0, 16).replace('T', ' ')}
</TableCell>
<TableCell>{f.nickname || '—'}</TableCell>
<TableCell className="max-w-md whitespace-pre-wrap">{f.content}</TableCell>
<TableCell>{f.contact || '—'}</TableCell>
<TableCell>
{f.handled ? <Badge variant="success">已处理</Badge> : <Badge variant="muted">待处理</Badge>}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => toggle(f)}>
{f.handled ? '标为待处理' : '标为已处理'}
</Button>
</TableCell>
</TableRow>
))}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
还没有反馈
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}
-73
View File
@@ -1,73 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, setToken } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function Login() {
// 不预填账号密码:预填等于把后台凭证写在页面上
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!username.trim() || !password) {
setError('请输入账号和密码')
return
}
setLoading(true)
setError('')
try {
const res = await api.login(username.trim(), password)
setToken(res.token, res.refresh_token)
navigate('/')
} catch (err: any) {
setError(err.message || '登录失败')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<div className="text-3xl text-center mb-2">🐾</div>
<CardTitle className="text-center text-xl">肉垫计划 · 后台管理</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label>账号</Label>
<Input
value={username}
autoComplete="username"
placeholder="请输入账号"
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>密码</Label>
<Input
type="password"
value={password}
autoComplete="current-password"
placeholder="请输入密码"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '登录中...' : '登录'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
-46
View File
@@ -1,46 +0,0 @@
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
export default function Members() {
const { page, setPage, data, totalPages } = usePaged<any>(api.memberships)
return (
<div>
<h1 className="text-2xl font-bold mb-6">会员管理</h1>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>用户ID</TableHead>
<TableHead>状态</TableHead>
<TableHead>类型</TableHead>
<TableHead>价格</TableHead>
<TableHead>到期</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((m) => (
<TableRow key={m.id}>
<TableCell>{m.id}</TableCell>
<TableCell>{m.user_id}</TableCell>
<TableCell>
{m.status === 'active' ? <Badge variant="success">生效中</Badge> : <Badge variant="muted">{m.status}</Badge>}
</TableCell>
<TableCell>{m.plan_type || '—'}</TableCell>
<TableCell>¥{m.price}</TableCell>
<TableCell>{m.end_date ? String(m.end_date).slice(0, 10) : '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}
-46
View File
@@ -1,46 +0,0 @@
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
export default function Pets() {
const { page, setPage, data, totalPages } = usePaged<any>(api.pets)
return (
<div>
<h1 className="text-2xl font-bold mb-6">宠物管理</h1>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>宠物</TableHead>
<TableHead>类型</TableHead>
<TableHead>阶段</TableHead>
<TableHead>体重</TableHead>
<TableHead>主人ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.id}</TableCell>
<TableCell className="font-medium">
<span className="mr-1">{p.emoji}</span>
{p.name}
</TableCell>
<TableCell>{p.type}</TableCell>
<TableCell>{p.stage}</TableCell>
<TableCell>{p.weight}</TableCell>
<TableCell>{p.user_id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}
-154
View File
@@ -1,154 +0,0 @@
import { useState } from 'react'
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
const statusMap: Record<string, { label: string; variant: any }> = {
pending: { label: '待审核', variant: 'warning' },
published: { label: '已发布', variant: 'success' },
rejected: { label: '已打回', variant: 'destructive' },
hidden: { label: '已隐藏', variant: 'muted' },
deleted: { label: '已删除', variant: 'destructive' },
}
const FILTERS = [
{ label: '待审核', status: 'pending' },
{ label: '已发布', status: 'published' },
{ label: '已打回', status: 'rejected' },
{ label: '全部', status: '' },
]
// 后台展示的图片可能是真实 URL,也可能是机器人帖的 emoji 占位——只渲染 URL
function isUrl(s: string) {
return typeof s === 'string' && s.indexOf('http') === 0
}
function fmtTime(iso?: string) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
const p = (n: number) => String(n).padStart(2, '0')
const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
const now = new Date()
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`
const sameYear = d.getFullYear() === now.getFullYear()
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
}
export default function Posts() {
const [status, setStatus] = useState('pending')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts, { status })
async function setPostStatus(id: number, s: string) {
await api.setPostStatus(id, s)
reload()
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">帖子审核</h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{FILTERS.map((f) => (
<button
key={f.status}
onClick={() => { setStatus(f.status); setPage(1) }}
className={`rounded-md px-3 py-1 text-sm transition ${
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>作者</TableHead>
<TableHead>内容</TableHead>
<TableHead>配图</TableHead>
<TableHead>赞/评</TableHead>
<TableHead>时间</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((p) => {
const imgs: string[] = Array.isArray(p.images) ? p.images.filter(isUrl) : []
return (
<TableRow key={p.id}>
<TableCell className="whitespace-nowrap align-top">
<span className="mr-1">{p.author_emoji}</span>
{p.author_name}
{p.is_ai && <Badge variant="muted" className="ml-1">AI</Badge>}
</TableCell>
<TableCell className="max-w-xs align-top">
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{p.content || '—'}</div>
</TableCell>
<TableCell className="align-top">
{imgs.length ? (
<div className="flex flex-wrap gap-1" style={{ maxWidth: 220 }}>
{imgs.map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer">
<img
src={u}
className="h-16 w-16 rounded-md object-cover border"
loading="lazy"
/>
</a>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">无</span>
)}
</TableCell>
<TableCell className="whitespace-nowrap align-top">
{p.like_count} / {p.comment_count}
</TableCell>
<TableCell className="whitespace-nowrap align-top text-muted-foreground">
{fmtTime(p.created_at)}
</TableCell>
<TableCell className="align-top">
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
</TableCell>
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
{p.status !== 'published' && (
<Button variant="default" size="sm" onClick={() => setPostStatus(p.id, 'published')}>
通过
</Button>
)}
{(p.status === 'pending' || p.status === 'published') && (
<Button variant="outline" size="sm" onClick={() => setPostStatus(p.id, 'rejected')}>
打回
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => setPostStatus(p.id, 'deleted')}>
删除
</Button>
</TableCell>
</TableRow>
)
})}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
这个状态下暂无帖子
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}
-275
View File
@@ -1,275 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
type Field = { kind: string; label: string; unit?: string; options?: string[]; to_category: boolean }
type RT = {
id: string
code: string
label: string
icon: string
group: string
sort: number
enabled: boolean
form: string
locked: boolean
fields_raw: string
fields: Field[]
}
const empty: any = {
id: '', code: '', label: '', icon: 'note', group: 'daily',
sort: 0, enabled: true, form: 'simple', locked: false, fields_raw: '',
}
const GROUPS = [
{ key: 'daily', label: '日常' },
{ key: 'health', label: '健康' },
{ key: 'care', label: '洗护' },
{ key: 'clean', label: '清洁' },
]
const gLabel = (k: string) => GROUPS.find((g) => g.key === k)?.label || k
// 字段配置那段紧凑写法的说明。放在弹窗里而不是文档里——
// 这东西一年用两次,去翻文档的成本比它本身还高
const HINT = `number:体重:kg 数值,落 num_value
options:状态:正常|软便|拉稀 单选,落 category
options:症状:呕吐|拉稀:- 末尾 - 表示不落 category(只进标题)
text:吃了什么 文本,只进标题
(留空) 只要「时间 + 描述 + 照片」`
export default function RecordTypes() {
const [list, setList] = useState<RT[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<any>(empty)
const [err, setErr] = useState('')
function reload() {
api
.recordTypes()
.then((r) => setList(r || []))
.catch(() => setList([]))
}
useEffect(reload, [])
function create() {
setForm({ ...empty })
setErr('')
setOpen(true)
}
function edit(t: RT) {
setForm({ ...t })
setErr('')
setOpen(true)
}
async function save() {
setErr('')
try {
await api.saveRecordType(form)
setOpen(false)
reload()
} catch (e: any) {
// 字段配置写错了后端会说第几行错在哪,原样透出
setErr(e?.message || '保存失败')
}
}
async function del(t: RT) {
if (!confirm(`删除「${t.label}」?`)) return
try {
await api.deleteRecordType(t.id)
reload()
} catch (e: any) {
alert(e?.message || '删除失败')
}
}
const set = (k: string) => (e: any) => setForm({ ...form, [k]: e.target.value })
// 字段配置渲染成人话,省得盯着 DSL 猜
function fieldsText(t: RT) {
if (!t.fields?.length) return <span className="text-muted-foreground">时间 + 描述 + 照片</span>
return (
<span className="space-x-2">
{t.fields.map((f, i) => (
<span key={i} className="whitespace-nowrap text-xs">
{f.label}
{f.kind === 'number' && `·数值${f.unit || ''}`}
{f.kind === 'text' && '·文本'}
{f.kind === 'options' && `·单选${f.to_category ? '★' : ''}`}
</span>
))}
</span>
)
}
const byGroup: Record<string, RT[]> = {}
list.forEach((t) => ((byGroup[t.group] = byGroup[t.group] || []).push(t)))
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">记录事项</h1>
<p className="mt-1 text-sm text-muted-foreground">
小程序「记一笔」的事项和它们的表单字段。★ 标记的单选会落到 category ——
周报的高风险数、账单的分类统计都读这一列。
</p>
</div>
<Button onClick={create}>新增事项</Button>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">分组</TableHead>
<TableHead className="w-28">名称</TableHead>
<TableHead className="w-28">code</TableHead>
<TableHead>表单字段</TableHead>
<TableHead className="w-16">排序</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-32 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{GROUPS.map((g) =>
(byGroup[g.key] || []).map((t, i) => (
<TableRow key={t.id}>
<TableCell className="text-muted-foreground">{i === 0 ? g.label : ''}</TableCell>
<TableCell className="font-medium">{t.label}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{t.code}</TableCell>
<TableCell>{fieldsText(t)}</TableCell>
<TableCell className="text-muted-foreground">{t.sort}</TableCell>
<TableCell>
{t.enabled ? <Badge>启用</Badge> : <Badge variant="secondary">停用</Badge>}
{t.locked && (
<Badge variant="outline" className="ml-1">
内置
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(t)}>
编辑
</Button>
{!t.locked && (
<Button variant="ghost" size="sm" onClick={() => del(t)}>
删除
</Button>
)}
</TableCell>
</TableRow>
)),
)}
{!list.length && (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
还没有事项 —— 小程序的记录页会是空的
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{form.id ? `编辑「${form.label}」` : '新增事项'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>名称</Label>
<Input value={form.label} onChange={set('label')} placeholder="洗澡" />
</div>
<div className="grid gap-2">
<Label>code(落库的 type,建过记录后不能改)</Label>
<Input
value={form.code}
onChange={set('code')}
placeholder="bath"
className="font-mono"
disabled={form.locked}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>分组</Label>
<div className="flex flex-wrap gap-2">
{GROUPS.map((g) => (
<Button
key={g.key}
type="button"
variant={form.group === g.key ? 'default' : 'outline'}
size="sm"
onClick={() => setForm({ ...form, group: g.key })}
>
{g.label}
</Button>
))}
</div>
</div>
<div className="grid gap-2">
<Label>图标名(pt-icon)</Label>
<Input value={form.icon} onChange={set('icon')} placeholder="bath" className="font-mono" />
</div>
</div>
<div className="grid gap-2">
<Label>表单字段(一行一个,留空则只有「时间 + 描述 + 照片」)</Label>
<textarea
className="min-h-[120px] w-full rounded-md border border-input bg-card px-3 py-2 font-mono text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={form.fields_raw || ''}
onChange={set('fields_raw')}
placeholder="number:体重:kg"
/>
<pre className="whitespace-pre-wrap rounded bg-muted p-3 text-xs text-muted-foreground">
{HINT}
</pre>
<p className="text-xs text-muted-foreground">
一个事项里最多一个字段能落 category。多出来的那个末尾加 <code>:-</code>。
</p>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Label>排序</Label>
<Input
type="number"
value={form.sort}
onChange={(e) => setForm({ ...form, sort: Number(e.target.value) || 0 })}
className="w-24"
/>
</div>
<div className="flex items-center gap-2">
<input
id="rt-enabled"
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<Label htmlFor="rt-enabled" className="cursor-pointer">
启用
</Label>
</div>
</div>
{form.locked && (
<p className="text-xs text-muted-foreground">
这是内置事项:统计和分析逻辑依赖它的 code,不能删也不能改 code。想隐藏请改成停用。
</p>
)}
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save}>保存</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
-92
View File
@@ -1,92 +0,0 @@
import { useState } from 'react'
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
export default function Users() {
const [keyword, setKeyword] = useState('')
const [query, setQuery] = useState('')
const [type, setType] = useState('')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.users, { keyword: query, type })
async function toggle(u: any) {
await api.setUserDisabled(u.id, !u.disabled)
reload()
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">用户管理</h1>
<div className="flex gap-2 mb-4">
<Input
placeholder="搜索昵称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="max-w-xs"
onKeyDown={(e) => e.key === 'Enter' && (setPage(1), setQuery(keyword))}
/>
<Button onClick={() => (setPage(1), setQuery(keyword))}>搜索</Button>
<select
className="h-9 rounded-md border border-input bg-card px-3 text-sm"
value={type}
onChange={(e) => (setPage(1), setType(e.target.value))}
>
<option value="">全部账号</option>
<option value="real">真实用户</option>
<option value="bot">机器人(AI)</option>
</select>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>昵称</TableHead>
<TableHead>手机号</TableHead>
<TableHead>宠物档案</TableHead>
<TableHead>建档</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.id}</TableCell>
<TableCell className="font-medium">
{u.nickname}
{u.is_bot && (
<Badge variant="muted" className="ml-2">
AI
</Badge>
)}
</TableCell>
<TableCell>{u.phone || '—'}</TableCell>
<TableCell className="whitespace-nowrap">
{u.pet_count > 0 ? `${u.pet_count} 个` : <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell>{u.onboarded ? '已建档' : '未建档'}</TableCell>
<TableCell>
{u.disabled ? <Badge variant="destructive">已禁用</Badge> : <Badge variant="success">正常</Badge>}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => toggle(u)}>
{u.disabled ? '启用' : '禁用'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}