From 978d9eeb2b15131086a60b110daf37af3d9dffcc Mon Sep 17 00:00:00 2001 From: selfrelease Date: Mon, 31 Aug 2026 15:53:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E7=AB=AF=E4=B8=93?= =?UTF-8?q?=E5=AE=B6=E7=AE=A1=E7=90=86=E5=AE=8C=E6=95=B4CRUD(=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=88=9B=E5=BB=BA=E5=92=8C=E5=88=A0=E9=99=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 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> --- api.py | 38 ++++++ bossai.py | 68 ++++++++++ web2/src/lib/api.ts | 9 ++ web2/src/pages/admin/AdminExperts.tsx | 179 +++++++++++++++++++++++++- 4 files changed, 292 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index e8fc827..12c32ab 100644 --- a/api.py +++ b/api.py @@ -1644,6 +1644,44 @@ def admin_list_experts(status: str | None = None): 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") def admin_update_expert_status(expert_id: int, body: dict): """管理端:上架/下架专家。""" diff --git a/bossai.py b/bossai.py index b117ca9..0f2788c 100644 --- a/bossai.py +++ b/bossai.py @@ -1519,6 +1519,74 @@ class ExpertStore: return None 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, category: str) -> dict: """用户选定专家,创建服务关系(待专家确认)。 diff --git a/web2/src/lib/api.ts b/web2/src/lib/api.ts index bcc1bd1..48506b0 100644 --- a/web2/src/lib/api.ts +++ b/web2/src/lib/api.ts @@ -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 }), diff --git a/web2/src/pages/admin/AdminExperts.tsx b/web2/src/pages/admin/AdminExperts.tsx index f4123a9..8722395 100644 --- a/web2/src/pages/admin/AdminExperts.tsx +++ b/web2/src/pages/admin/AdminExperts.tsx @@ -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(null) const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [deleting, setDeleting] = useState(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 ( @@ -61,7 +80,13 @@ export function AdminExperts() { } return ( - + setCreating(true)}> + 新增专家 + + } + > {experts.length === 0 ? ( ) : ( @@ -123,6 +148,14 @@ export function AdminExperts() { ex.status === 'active' ? : } {ex.status === 'active' ? '下架' : '上架'} + @@ -140,6 +173,33 @@ export function AdminExperts() { onSuccess={() => { loadExperts(); setEditing(null) }} /> )} + + {/* 新增专家弹窗 */} + {creating && ( + setCreating(false)} + onSuccess={() => { loadExperts(); setCreating(false) }} + /> + )} + + {/* 删除确认弹窗 */} + !o && setDeleting(null)}> + + + 删除专家 + + 确定要删除专家「{deleting?.name}」吗?将同时删除其所有服务关系和咨询记录,此操作不可恢复。 + + + + + + + + ) } @@ -309,3 +369,118 @@ function EditExpertDialog({ ) } + +/** 新增专家弹窗:含登录账号 + 基本信息 */ +function CreateExpertDialog({ + onClose, + onSuccess, +}: { + onClose: () => void + onSuccess: () => void +}) { + const [name, setName] = useState('') + const [phone, setPhone] = useState('') + const [password, setPassword] = useState('') + const [category, setCategory] = useState('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 ( + !o && onClose()}> + + + 新增专家 + 创建专家账号,填写基本信息和登录密码 + + +
+ {/* 姓名 */} +
+ + setName(e.target.value)} placeholder="专家姓名" /> +
+ + {/* 手机号 */} +
+ + setPhone(e.target.value)} placeholder="如:13800138000" /> +
+ + {/* 密码 */} +
+ + setPassword(e.target.value)} placeholder="设置初始密码" /> +
+ + {/* 分类 */} +
+ + +
+ + {/* 职称 */} +
+ + setTitle(e.target.value)} placeholder="如:高级律师" /> +
+ + {/* 简介 */} +
+ +