feat: 养护模板/社区运营/体重趋势/计划日历 + 雪花ID重构 + 文件表与图片上传

后端
- 主键改雪花字符串 ID(pkg/idgen + Base.BeforeCreate),全表外键/JWT/中间件随之调整
- 新增 files 表:/api/upload 按 MD5 去重,返回 {id,url,md5},服务端只收图片
- 头像/记录附图/帖子图改 file_id 关联,读取解析为 URL
- 养护模板(物种×阶段)后台可配 + AI 生成草稿;建档按模板生成任务/计划/提醒
- 社区 AI 运营:虚拟账号池 + 每日定时/手动生成,帖子带 AI 标
- 计划路线图节点带真实日期;首页周历与计划日历同源;新增 day-plan 当日安排
- 体重趋势接口带备注;记录列表分页

小程序
- 公共图片上传 utils/upload.js(仅图片);记录拍照/我的头像/社区发图三处接入
- 首页日历点选查当日任务;记录页体重趋势可点看备注 + 时间轴分页折叠
- 计划页日历点选查当日计划;自定义 tabBar 高度调整

后台(React)
- 新增「养护模板」「社区运营」页;用户管理加机器人筛选

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-06 08:48:15 +08:00
parent 609f7d06cf
commit 2f5dd6eefc
83 changed files with 2253 additions and 355 deletions
+4
View File
@@ -9,6 +9,8 @@ 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 CommunityBot from '@/pages/CommunityBot'
import Members from '@/pages/Members'
function RequireAuth({ children }: { children: JSX.Element }) {
@@ -33,6 +35,8 @@ export default function App() {
<Route path="posts" element={<Posts />} />
<Route path="comments" element={<Comments />} />
<Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} />
<Route path="community-bot" element={<CommunityBot />} />
<Route path="members" element={<Members />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+3 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, Crown, LogOut } from 'lucide-react'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, Crown, LogOut } from 'lucide-react'
import { clearToken } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -11,6 +11,8 @@ const nav = [
{ to: '/posts', label: '帖子审核', icon: MessageSquare },
{ to: '/comments', label: '评论管理', icon: MessagesSquare },
{ to: '/articles', label: '文章管理', icon: FileText },
{ to: '/care-templates', label: '养护模板', icon: ClipboardList },
{ to: '/community-bot', label: '社区运营', icon: Sparkles },
{ to: '/members', label: '会员管理', icon: Crown },
]
+21
View File
@@ -67,6 +67,27 @@ export const api = {
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'),
careTemplate: (species: string, stage: string) =>
http.get<any, { species: string; stage: string; items: any[] }>('/admin/care-templates', {
params: { species, stage },
}),
saveCareTemplate: (species: string, stage: string, items: any[]) =>
http.put<any, { items: any[] }>('/admin/care-templates', { species, stage, items }),
genCareTemplate: (species: string, stage: string) =>
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
species,
stage,
}),
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
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Cat, Dog, Wand2, Plus, Trash2, Save, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
const SPECIES = [
{ key: 'cat', label: '猫猫', Icon: Cat },
{ key: 'dog', label: '狗狗', Icon: Dog },
]
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']
const SHEET_LABEL: Record<string, string> = {
'': '无', weight: '体重', poop: '便便', food: '饮食', symptom: '异常',
vaccine: '疫苗', medicine: '用药', report: '报告', taskDetail: '任务详情',
}
const REMINDER_LABEL: Record<string, string> = {
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报',
}
const TASK_SHEETS = ['', 'weight', 'poop', 'food', 'symptom', 'vaccine', 'medicine']
const PLAN_SHEETS = ['', 'weight', 'vaccine', 'medicine', 'report', 'taskDetail']
const REMINDER_TYPES = ['vaccine', 'deworm', 'weight', 'monthlyReport']
const selectCls =
'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0'
type Item = {
_k: number
kind: 'task' | 'plan' | 'reminder'
title: string
description: string
priority: string
sheet_type: string
day: number
day_label: string
reminder_type: string
offset_days: number
frequency: string
}
let uid = 1
function normalize(raw: any[]): Item[] {
return (raw || []).map((r) => ({
_k: uid++,
kind: r.kind,
title: r.title || '',
description: r.description || '',
priority: r.priority || '',
sheet_type: r.sheet_type || '',
day: r.day ?? 0,
day_label: r.day_label || '',
reminder_type: r.reminder_type || 'vaccine',
offset_days: r.offset_days ?? 0,
frequency: r.frequency || '',
}))
}
function blank(kind: Item['kind']): Item {
return {
_k: uid++, kind, title: '', description: '', priority: '', sheet_type: '',
day: 0, day_label: '', reminder_type: 'vaccine', offset_days: 0, frequency: '',
}
}
export default function CareTemplates() {
const [species, setSpecies] = useState('cat')
const [stage, setStage] = useState('成年期')
const [items, setItems] = useState<Item[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [gen, setGen] = useState(false)
const [dirty, setDirty] = useState(false)
const [msg, setMsg] = useState('')
const reqId = useRef(0)
async function load(sp: string, st: string) {
const my = ++reqId.current
setLoading(true)
setMsg('')
try {
const data = await api.careTemplate(sp, st)
if (my === reqId.current) {
setItems(normalize(data.items))
setDirty(false)
}
} finally {
if (my === reqId.current) setLoading(false)
}
}
useEffect(() => {
load(species, stage)
}, [species, stage])
function update(k: number, key: keyof Item, value: any) {
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [key]: value } : it)))
setDirty(true)
}
function remove(k: number) {
setItems((prev) => prev.filter((it) => it._k !== k))
setDirty(true)
}
function add(kind: Item['kind']) {
setItems((prev) => [...prev, blank(kind)])
setDirty(true)
}
async function generate() {
setGen(true)
setMsg('')
try {
const data = await api.genCareTemplate(species, stage)
setItems(normalize(data.items))
setDirty(true)
setMsg('已生成草稿,确认无误后点「保存该模板」')
} catch (e: any) {
setMsg(e?.message || 'AI 生成失败')
} finally {
setGen(false)
}
}
async function save() {
setSaving(true)
setMsg('')
try {
await api.saveCareTemplate(species, stage, items)
setDirty(false)
setMsg('已保存')
} catch (e: any) {
setMsg(e?.message || '保存失败')
} finally {
setSaving(false)
}
}
const tasks = useMemo(() => items.filter((i) => i.kind === 'task'), [items])
const plans = useMemo(() => items.filter((i) => i.kind === 'plan'), [items])
const reminders = useMemo(() => items.filter((i) => i.kind === 'reminder'), [items])
return (
<div>
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
/
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={generate} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'}
</Button>
<Button onClick={save} disabled={saving || !dirty}>
<Save className="h-4 w-4" /> {saving ? '保存中…' : '保存该模板'}
</Button>
</div>
</div>
<div className="flex gap-2 mb-2">
{SPECIES.map((s) => (
<button
key={s.key}
onClick={() => setSpecies(s.key)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors',
species === s.key ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
)}
>
<s.Icon className="h-4 w-4" /> {s.label}
</button>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
{STAGES.map((st) => (
<button
key={st}
onClick={() => setStage(st)}
className={cn(
'rounded-full border px-3 py-1.5 text-sm transition-colors',
stage === st ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
)}
>
{st}
</button>
))}
</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">
<Section title="今日任务" count={tasks.length} onAdd={() => add('task')}>
{tasks.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input className="flex-1 min-w-40" placeholder="任务标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<Input className="flex-1 min-w-40" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
<select className={selectCls} value={it.priority} onChange={(e) => update(it._k, 'priority', e.target.value)}>
<option value=""></option>
<option value="重要"></option>
</select>
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
{TASK_SHEETS.map((s) => <option key={s} value={s}>·{SHEET_LABEL[s]}</option>)}
</select>
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<Section title="30 天计划" count={plans.length} onAdd={() => add('plan')}>
{plans.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input type="number" className="w-16" placeholder="第N天" value={it.day} onChange={(e) => update(it._k, 'day', Number(e.target.value))} />
<Input className="w-24" placeholder="标签" value={it.day_label} onChange={(e) => update(it._k, 'day_label', e.target.value)} />
<Input className="flex-1 min-w-36" placeholder="节点标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<Input className="flex-1 min-w-36" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
{PLAN_SHEETS.map((s) => <option key={s} value={s}>·{SHEET_LABEL[s]}</option>)}
</select>
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<Section title="提醒" count={reminders.length} onAdd={() => add('reminder')}>
{reminders.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<select className={selectCls} value={it.reminder_type} onChange={(e) => update(it._k, 'reminder_type', e.target.value)}>
{REMINDER_TYPES.map((s) => <option key={s} value={s}>{REMINDER_LABEL[s]}</option>)}
</select>
<Input className="flex-1 min-w-40" placeholder="提醒标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<div className="flex items-center gap-1 shrink-0">
<span className="text-sm text-muted-foreground">+</span>
<Input type="number" className="w-16" value={it.offset_days} onChange={(e) => update(it._k, 'offset_days', Number(e.target.value))} />
<span className="text-sm text-muted-foreground"></span>
</div>
<Input className="flex-1 min-w-36" placeholder="或填频率(到期天数填0)" value={it.frequency} onChange={(e) => update(it._k, 'frequency', e.target.value)} />
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5" /> 退
</p>
</div>
)}
</div>
)
}
function Section({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
return (
<Card>
<CardContent className="pt-4 pb-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium">
{title} <span className="text-muted-foreground">{count}</span>
</div>
<Button variant="outline" size="sm" onClick={onAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{count === 0 ? <p className="text-sm text-muted-foreground"></p> : children}
</div>
</CardContent>
</Card>
)
}
function DelBtn({ onClick }: { onClick: () => void }) {
return (
<Button variant="ghost" size="icon" className="text-muted-foreground shrink-0" onClick={onClick} aria-label="删除">
<Trash2 className="h-4 w-4" />
</Button>
)
}
@@ -0,0 +1,149 @@
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 }
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('')
useEffect(() => {
api.communityBot().then((c) => {
setCfg(c)
setLoading(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>
)}
</div>
)
}
+19 -2
View File
@@ -11,7 +11,8 @@ import { Pager } from '@/components/Pager'
export default function Users() {
const [keyword, setKeyword] = useState('')
const [query, setQuery] = useState('')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.users, { keyword: query })
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)
@@ -30,6 +31,15 @@ export default function Users() {
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">
@@ -48,7 +58,14 @@ export default function Users() {
{data?.list?.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.id}</TableCell>
<TableCell className="font-medium">{u.nickname}</TableCell>
<TableCell className="font-medium">
{u.nickname}
{u.is_bot && (
<Badge variant="muted" className="ml-2">
AI
</Badge>
)}
</TableCell>
<TableCell>{u.phone || '—'}</TableCell>
<TableCell>{u.onboarded ? '已建档' : '未建档'}</TableCell>
<TableCell>