feat: 管理端专家管理完整CRUD(新增创建和删除)
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

后端:
- ExpertStore.create_expert: 创建专家(含bcrypt密码哈希)
- ExpertStore.delete_expert: 级联删除(咨询+服务关系+专家)
- POST /admin/experts: 创建专家API
- DELETE /admin/experts/{id}: 删除专家API

前端:
- AdminExperts页面加"新增专家"按钮和创建弹窗
- 创建弹窗含姓名/手机号/密码/分类/职称/简介/专长
- 每个专家卡片加删除按钮,二次确认后删除
- api.ts加adminCreateExpert和adminDeleteExpert方法

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-31 15:53:45 +08:00
co-authored by Devin
parent 17533278da
commit 978d9eeb2b
4 changed files with 292 additions and 2 deletions
+9
View File
@@ -291,6 +291,15 @@ export const api = {
// ===== 管理后台 =====
adminListExperts: (status?: string) =>
request<{ results: Expert[] }>(`/admin/experts${status ? `?status=${status}` : ''}`),
adminCreateExpert: (data: {
name: string; phone: string; password: string; category: string;
title?: string; bio?: string; expertise?: string; avatar_url?: string | null;
status?: string;
}) => request<{ created: boolean; expert: Expert }>('/admin/experts', {
method: 'POST', body: JSON.stringify(data),
}),
adminDeleteExpert: (expertId: number) =>
request<{ deleted: boolean; expert_id: number }>(`/admin/experts/${expertId}`, { method: 'DELETE' }),
adminUpdateExpertStatus: (expertId: number, status: 'active' | 'inactive') =>
request<{ updated: boolean; expert_id: number; status: string }>(`/admin/experts/${expertId}/status`, {
method: 'PATCH', body: JSON.stringify({ status }),
+177 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Briefcase, Loader2, Pencil, Power, PowerOff, Star, Trash2, Upload } from 'lucide-react'
import { Briefcase, Loader2, Pencil, Power, PowerOff, Star, Trash2, Upload, UserPlus } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState'
@@ -27,6 +27,9 @@ export function AdminExperts() {
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState<number | null>(null)
const [editing, setEditing] = useState<Expert | null>(null)
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState<Expert | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const loadExperts = async () => {
try {
@@ -52,6 +55,22 @@ export function AdminExperts() {
}
}
/** 确认删除专家 */
const handleDelete = async () => {
if (!deleting) return
setDeleteLoading(true)
try {
await api.adminDeleteExpert(deleting.id)
toast.success(`已删除专家 ${deleting.name}`)
setDeleting(null)
loadExperts()
} catch (e) {
toast.error(e instanceof Error ? e.message : '删除失败')
} finally {
setDeleteLoading(false)
}
}
if (loading) {
return (
<PageLayout title="专家管理" description="专家上下架管理">
@@ -61,7 +80,13 @@ export function AdminExperts() {
}
return (
<PageLayout title="专家管理" description="专家上下架、编辑信息、上传头像">
<PageLayout title="专家管理" description="专家上下架、编辑信息、上传头像"
actions={
<Button size="sm" onClick={() => setCreating(true)}>
<UserPlus className="h-3.5 w-3.5" /> 新增专家
</Button>
}
>
{experts.length === 0 ? (
<EmptyState title="暂无专家" icon={Briefcase} />
) : (
@@ -123,6 +148,14 @@ export function AdminExperts() {
ex.status === 'active' ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
{ex.status === 'active' ? '下架' : '上架'}
</Button>
<Button
size="sm"
variant="outline"
className="text-rose-600 hover:bg-rose-50"
onClick={() => setDeleting(ex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
@@ -140,6 +173,33 @@ export function AdminExperts() {
onSuccess={() => { loadExperts(); setEditing(null) }}
/>
)}
{/* 新增专家弹窗 */}
{creating && (
<CreateExpertDialog
onClose={() => setCreating(false)}
onSuccess={() => { loadExperts(); setCreating(false) }}
/>
)}
{/* 删除确认弹窗 */}
<Dialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>删除专家</DialogTitle>
<DialogDescription>
确定要删除专家「{deleting?.name}」吗?将同时删除其所有服务关系和咨询记录,此操作不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)}>取消</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading}>
{deleteLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
)
}
@@ -309,3 +369,118 @@ function EditExpertDialog({
</Dialog>
)
}
/** 新增专家弹窗:含登录账号 + 基本信息 */
function CreateExpertDialog({
onClose,
onSuccess,
}: {
onClose: () => void
onSuccess: () => void
}) {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [category, setCategory] = useState<Expert['category']>('legal')
const [title, setTitle] = useState('')
const [bio, setBio] = useState('')
const [expertise, setExpertise] = useState('')
const [saving, setSaving] = useState(false)
const handleSave = async () => {
if (!name.trim()) { toast.error('姓名不能为空'); return }
if (!phone.trim()) { toast.error('手机号不能为空'); return }
if (password.length < 6) { toast.error('密码至少 6 位'); return }
setSaving(true)
try {
await api.adminCreateExpert({
name: name.trim(),
phone: phone.trim(),
password,
category,
title: title.trim(),
bio: bio.trim(),
expertise: expertise.trim(),
status: 'active',
})
toast.success('专家创建成功')
onSuccess()
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建失败')
} finally {
setSaving(false)
}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>新增专家</DialogTitle>
<DialogDescription>创建专家账号,填写基本信息和登录密码</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto py-2">
{/* 姓名 */}
<div className="space-y-1.5">
<Label htmlFor="create-name">姓名 *</Label>
<Input id="create-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="专家姓名" />
</div>
{/* 手机号 */}
<div className="space-y-1.5">
<Label htmlFor="create-phone">手机号(登录账号) *</Label>
<Input id="create-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="如:13800138000" />
</div>
{/* 密码 */}
<div className="space-y-1.5">
<Label htmlFor="create-password">登录密码(至少 6 位) *</Label>
<Input id="create-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="设置初始密码" />
</div>
{/* 分类 */}
<div className="space-y-1.5">
<Label>分类</Label>
<Select value={category} onValueChange={(v) => setCategory(v as Expert['category'])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="legal">法务</SelectItem>
<SelectItem value="decision">经营</SelectItem>
<SelectItem value="emotion">情绪</SelectItem>
<SelectItem value="life">生活</SelectItem>
</SelectContent>
</Select>
</div>
{/* 职称 */}
<div className="space-y-1.5">
<Label htmlFor="create-title">职称 / 头衔</Label>
<Input id="create-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:高级律师" />
</div>
{/* 简介 */}
<div className="space-y-1.5">
<Label htmlFor="create-bio">简介</Label>
<Textarea id="create-bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="专家背景介绍" className="min-h-[80px]" />
</div>
{/* 专长 */}
<div className="space-y-1.5">
<Label htmlFor="create-expertise">专长(逗号分隔)</Label>
<Input id="create-expertise" value={expertise} onChange={(e) => setExpertise(e.target.value)} placeholder="如:劳动用工,合同审查,股权纠纷" />
<p className="text-xs text-muted-foreground">多个专长用英文逗号分隔</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}