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:
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user