feat: 管理端专家管理完整CRUD(新增创建和删除)
后端:
- 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:
@@ -1644,6 +1644,44 @@ def admin_list_experts(status: str | None = None):
|
|||||||
return {"results": expert_store.list_experts(status=status)}
|
return {"results": expert_store.list_experts(status=status)}
|
||||||
|
|
||||||
|
|
||||||
|
class ExpertCreateRequest(BaseModel):
|
||||||
|
"""创建专家请求。"""
|
||||||
|
name: str
|
||||||
|
phone: str
|
||||||
|
password: str
|
||||||
|
category: str
|
||||||
|
title: str = ""
|
||||||
|
bio: str = ""
|
||||||
|
expertise: str = ""
|
||||||
|
avatar_url: str | None = None
|
||||||
|
status: str = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/experts")
|
||||||
|
def admin_create_expert(req: ExpertCreateRequest):
|
||||||
|
"""管理端:创建专家(含登录账号)。"""
|
||||||
|
if len(req.password) < 6:
|
||||||
|
raise HTTPException(400, "密码至少 6 位")
|
||||||
|
try:
|
||||||
|
expert = expert_store.create_expert(
|
||||||
|
name=req.name, phone=req.phone, password=req.password,
|
||||||
|
category=req.category, title=req.title, bio=req.bio,
|
||||||
|
expertise=req.expertise, avatar_url=req.avatar_url,
|
||||||
|
status=req.status,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
return {"created": True, "expert": expert}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/admin/experts/{expert_id}")
|
||||||
|
def admin_delete_expert(expert_id: int):
|
||||||
|
"""管理端:删除专家(级联删除服务关系和咨询)。"""
|
||||||
|
if not expert_store.delete_expert(expert_id):
|
||||||
|
raise HTTPException(404, "专家不存在")
|
||||||
|
return {"deleted": True, "expert_id": expert_id}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/admin/experts/{expert_id}/status")
|
@app.patch("/admin/experts/{expert_id}/status")
|
||||||
def admin_update_expert_status(expert_id: int, body: dict):
|
def admin_update_expert_status(expert_id: int, body: dict):
|
||||||
"""管理端:上架/下架专家。"""
|
"""管理端:上架/下架专家。"""
|
||||||
|
|||||||
@@ -1519,6 +1519,74 @@ class ExpertStore:
|
|||||||
return None
|
return None
|
||||||
return self.get_expert(expert_id)
|
return self.get_expert(expert_id)
|
||||||
|
|
||||||
|
def create_expert(self, name: str, phone: str, password: str,
|
||||||
|
category: str, title: str = "", bio: str = "",
|
||||||
|
expertise: str = "", avatar_url: str | None = None,
|
||||||
|
status: str = "active") -> dict:
|
||||||
|
"""创建专家(含登录账号)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 专家姓名
|
||||||
|
phone: 手机号(登录账号,唯一)
|
||||||
|
password: 明文密码(内部 bcrypt 哈希)
|
||||||
|
category: 分类 legal/decision/emotion/life
|
||||||
|
title: 职称
|
||||||
|
bio: 简介
|
||||||
|
expertise: 擅长领域(逗号分隔)
|
||||||
|
avatar_url: 头像 URL
|
||||||
|
status: active/inactive
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
新建的专家 dict
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 手机号已存在或分类非法
|
||||||
|
"""
|
||||||
|
if category not in self.CATEGORIES:
|
||||||
|
raise ValueError(f"分类应为 {self.CATEGORIES} 之一")
|
||||||
|
if status not in ("active", "inactive"):
|
||||||
|
raise ValueError("状态应为 active 或 inactive")
|
||||||
|
import bcrypt
|
||||||
|
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# 检查手机号唯一
|
||||||
|
cur.execute("SELECT 1 FROM experts WHERE phone = %s", (phone,))
|
||||||
|
if cur.fetchone():
|
||||||
|
raise ValueError("该手机号已注册为专家")
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO experts (name, phone, password, category, title, bio, "
|
||||||
|
"expertise, avatar_url, status) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) "
|
||||||
|
"RETURNING id, name, phone, category, title, bio, expertise, "
|
||||||
|
"avatar_url, rate, rating_count, status, created_at",
|
||||||
|
(name, phone, hashed, category, title or None, bio or None,
|
||||||
|
expertise or None, avatar_url, status),
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
return {"id": r[0], "name": r[1], "phone": r[2], "category": r[3],
|
||||||
|
"title": r[4], "bio": r[5], "expertise": r[6], "avatar_url": r[7],
|
||||||
|
"rate": r[8], "rating_count": r[9], "status": r[10],
|
||||||
|
"created_at": str(r[11])}
|
||||||
|
|
||||||
|
def delete_expert(self, expert_id: int) -> bool:
|
||||||
|
"""删除专家(级联删除服务关系和咨询)。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 表示删除成功,False 表示专家不存在
|
||||||
|
"""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# 先检查存在
|
||||||
|
cur.execute("SELECT 1 FROM experts WHERE id = %s", (expert_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
return False
|
||||||
|
# 级联删除咨询和服务关系
|
||||||
|
cur.execute("DELETE FROM expert_consultations WHERE expert_id = %s", (expert_id,))
|
||||||
|
cur.execute("DELETE FROM expert_services WHERE expert_id = %s", (expert_id,))
|
||||||
|
cur.execute("DELETE FROM experts WHERE id = %s", (expert_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
def create_service(self, user_phone: str, expert_id: int,
|
def create_service(self, user_phone: str, expert_id: int,
|
||||||
category: str) -> dict:
|
category: str) -> dict:
|
||||||
"""用户选定专家,创建服务关系(待专家确认)。
|
"""用户选定专家,创建服务关系(待专家确认)。
|
||||||
|
|||||||
@@ -291,6 +291,15 @@ export const api = {
|
|||||||
// ===== 管理后台 =====
|
// ===== 管理后台 =====
|
||||||
adminListExperts: (status?: string) =>
|
adminListExperts: (status?: string) =>
|
||||||
request<{ results: Expert[] }>(`/admin/experts${status ? `?status=${status}` : ''}`),
|
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') =>
|
adminUpdateExpertStatus: (expertId: number, status: 'active' | 'inactive') =>
|
||||||
request<{ updated: boolean; expert_id: number; status: string }>(`/admin/experts/${expertId}/status`, {
|
request<{ updated: boolean; expert_id: number; status: string }>(`/admin/experts/${expertId}/status`, {
|
||||||
method: 'PATCH', body: JSON.stringify({ status }),
|
method: 'PATCH', body: JSON.stringify({ status }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
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 { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
import { LoadingState } from '@/components/common/LoadingState'
|
import { LoadingState } from '@/components/common/LoadingState'
|
||||||
@@ -27,6 +27,9 @@ export function AdminExperts() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [toggling, setToggling] = useState<number | null>(null)
|
const [toggling, setToggling] = useState<number | null>(null)
|
||||||
const [editing, setEditing] = useState<Expert | 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 () => {
|
const loadExperts = async () => {
|
||||||
try {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<PageLayout title="专家管理" description="专家上下架管理">
|
<PageLayout title="专家管理" description="专家上下架管理">
|
||||||
@@ -61,7 +80,13 @@ export function AdminExperts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 ? (
|
{experts.length === 0 ? (
|
||||||
<EmptyState title="暂无专家" icon={Briefcase} />
|
<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' ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
|
||||||
{ex.status === 'active' ? '下架' : '上架'}
|
{ex.status === 'active' ? '下架' : '上架'}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -140,6 +173,33 @@ export function AdminExperts() {
|
|||||||
onSuccess={() => { loadExperts(); setEditing(null) }}
|
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>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -309,3 +369,118 @@ function EditExpertDialog({
|
|||||||
</Dialog>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user