Files
BossAi/web2/src/pages/admin/AdminExperts.tsx
T
selfreleaseandDevin 9310ba141d
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled
feat: 迁移专家工作台和管理后台到web2
专家工作台(4页面):
- WorkbenchPendingServices: 待确认服务
- WorkbenchPending: 待回复咨询
- WorkbenchReplied: 已回复咨询
- WorkbenchUsers: 服务中用户
- shared.tsx: 共享hook/弹窗/外壳

管理后台(5页面):
- AdminStats: 平台统计
- AdminAiUsage: AI使用情况(含图表)
- AdminExpertActivity: 专家沟通情况(含图表)
- AdminUsers: 用户管理(搜索/重置密码/停用)
- AdminExperts: 专家管理(上下架/编辑/头像)
- constants.ts: 标签映射和图表配色

路由: workbench/* 和 admin/* 接入实际页面
适配: EmptyState icon 从JSX元素改为ComponentType
修正: 管理端场景标签"法务参谋"→"私人法务"

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-31 15:46:01 +08:00

312 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Briefcase, Loader2, Pencil, Power, PowerOff, Star, Trash2, Upload } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState'
import { ExpertAvatar } from '@/components/common/ExpertAvatar'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api'
import type { Expert } from '@/types'
import { CATEGORY_LABELS } from './constants'
/** 管理后台 - 专家管理:专家上下架、编辑信息、上传头像 */
export function AdminExperts() {
const [experts, setExperts] = useState<Expert[]>([])
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState<number | null>(null)
const [editing, setEditing] = useState<Expert | null>(null)
const loadExperts = async () => {
try {
const res = await api.adminListExperts()
setExperts(res.results)
} catch { /* ignore */ }
finally { setLoading(false) }
}
useEffect(() => { loadExperts() }, [])
const handleToggle = async (expert: Expert) => {
setToggling(expert.id)
try {
const newStatus = expert.status === 'active' ? 'inactive' : 'active'
await api.adminUpdateExpertStatus(expert.id, newStatus)
toast.success(newStatus === 'active' ? '已上架' : '已下架')
loadExperts()
} catch {
toast.error('操作失败')
} finally {
setToggling(null)
}
}
if (loading) {
return (
<PageLayout title="专家管理" description="专家上下架管理">
<LoadingState />
</PageLayout>
)
}
return (
<PageLayout title="专家管理" description="专家上下架、编辑信息、上传头像">
{experts.length === 0 ? (
<EmptyState title="暂无专家" icon={Briefcase} />
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{experts.map((ex) => {
const stars = (ex.rate / 100).toFixed(1)
return (
<Card key={ex.id}>
<CardContent className="p-5 pt-5">
{/* 头部:长方形头像(左) + 姓名/状态(右) */}
<div className="flex items-start gap-4">
<ExpertAvatar name={ex.name} avatarUrl={ex.avatar_url} size="xl" shape="rect" className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium truncate">{ex.name}</h3>
{ex.status === 'active'
? <Badge>上架中</Badge>
: <Badge variant="outline">已下架</Badge>}
</div>
<p className="mt-0.5 text-sm text-muted-foreground truncate">{ex.title}</p>
<div className="mt-1 flex items-center gap-1 text-xs">
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
<span>{stars}</span>
<span className="text-muted-foreground">({ex.rating_count}人评价)</span>
</div>
{/* 简介 */}
{ex.bio && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">{ex.bio}</p>
)}
{/* 专长标签 */}
{ex.expertise && (
<div className="mt-2 flex flex-wrap gap-1">
{ex.expertise.split(',').map((tag, i) => (
<Badge key={i} variant="outline" className="text-xs">{tag.trim()}</Badge>
))}
</div>
)}
</div>
</div>
{/* 底部:分类 + 操作按钮 */}
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
<Badge variant="secondary">{CATEGORY_LABELS[ex.category] || ex.category}</Badge>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => setEditing(ex)}
>
<Pencil className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant={ex.status === 'active' ? 'destructive' : 'default'}
onClick={() => handleToggle(ex)}
disabled={toggling === ex.id}
>
{toggling === ex.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
ex.status === 'active' ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
{ex.status === 'active' ? '下架' : '上架'}
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* 编辑弹窗 */}
{editing && (
<EditExpertDialog
expert={editing}
onClose={() => setEditing(null)}
onSuccess={() => { loadExperts(); setEditing(null) }}
/>
)}
</PageLayout>
)
}
/** 编辑专家信息弹窗:含头像上传 + 表单字段 */
function EditExpertDialog({
expert,
onClose,
onSuccess,
}: {
expert: Expert
onClose: () => void
onSuccess: () => void
}) {
const [name, setName] = useState(expert.name)
const [category, setCategory] = useState(expert.category)
const [title, setTitle] = useState(expert.title)
const [bio, setBio] = useState(expert.bio)
const [expertise, setExpertise] = useState(expert.expertise)
const [avatarUrl, setAvatarUrl] = useState(expert.avatar_url)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
try {
const res = await api.adminUploadExpertAvatar(expert.id, file)
setAvatarUrl(res.avatar_url)
toast.success('头像上传成功')
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败')
} finally {
setUploading(false)
// 清空 input 允许重复选同一文件
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
/** 删除头像:清空 avatarUrl,保存时传空字符串后端清除为 NULL */
const handleRemoveAvatar = async () => {
setAvatarUrl('')
toast.success('头像已删除,保存后生效')
}
const handleSave = async () => {
if (!name.trim()) {
toast.error('姓名不能为空')
return
}
setSaving(true)
try {
await api.adminUpdateExpert(expert.id, {
name: name.trim(),
category,
title: title.trim(),
bio: bio.trim(),
expertise: expertise.trim(),
avatar_url: avatarUrl,
})
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="flex items-center gap-4">
<ExpertAvatar name={name || expert.name} avatarUrl={avatarUrl} size="2xl" shape="rect" />
<div className="space-y-2">
<p className="text-sm font-medium">专家头像</p>
<p className="text-xs text-muted-foreground">支持 JPG / PNG / WebP,最大 5MB</p>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
/>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{uploading ? '上传中…' : avatarUrl ? '更换图片' : '选择图片'}
</Button>
{avatarUrl && (
<Button
size="sm"
variant="outline"
className="text-rose-600 hover:bg-rose-50"
onClick={handleRemoveAvatar}
disabled={uploading}
>
<Trash2 className="h-3.5 w-3.5" /> 删除
</Button>
)}
</div>
</div>
</div>
{/* 姓名 */}
<div className="space-y-1.5">
<Label htmlFor="edit-name">姓名 *</Label>
<Input id="edit-name" value={name} onChange={(e) => setName(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="edit-title">职称 / 头衔</Label>
<Input id="edit-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:高级律师" />
</div>
{/* 简介 */}
<div className="space-y-1.5">
<Label htmlFor="edit-bio">简介</Label>
<Textarea id="edit-bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="专家背景介绍" className="min-h-[80px]" />
</div>
{/* 专长 */}
<div className="space-y-1.5">
<Label htmlFor="edit-expertise">专长(逗号分隔)</Label>
<Input id="edit-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" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}