feat: rebuild pet app and backend around core care flows
This commit is contained in:
@@ -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 端口。
|
||||
|
||||
@@ -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
@@ -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 +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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user