专家工作台(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>
312 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|