refactor: 专家创建/编辑合并为ExpertFormDialog,创建模式支持头像上传
This commit is contained in:
@@ -165,20 +165,12 @@ export function AdminExperts() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editing && (
|
||||
<EditExpertDialog
|
||||
{/* 编辑/新增弹窗 */}
|
||||
{(editing || creating) && (
|
||||
<ExpertFormDialog
|
||||
expert={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSuccess={() => { loadExperts(); setEditing(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新增专家弹窗 */}
|
||||
{creating && (
|
||||
<CreateExpertDialog
|
||||
onClose={() => setCreating(false)}
|
||||
onSuccess={() => { loadExperts(); setCreating(false) }}
|
||||
onClose={() => { setEditing(null); setCreating(false) }}
|
||||
onSuccess={() => { loadExperts(); setEditing(null); setCreating(false) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -204,40 +196,51 @@ export function AdminExperts() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑专家信息弹窗:含头像上传 + 表单字段 */
|
||||
function EditExpertDialog({
|
||||
/** 专家信息弹窗:创建/编辑共用,含头像上传 + 表单字段 */
|
||||
function ExpertFormDialog({
|
||||
expert,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
expert: Expert
|
||||
expert: Expert | null // null = 创建模式
|
||||
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 isCreate = !expert
|
||||
const [name, setName] = useState(expert?.name || '')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [category, setCategory] = useState<Expert['category']>(expert?.category || 'legal')
|
||||
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 || null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(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 = ''
|
||||
if (expert) {
|
||||
// 编辑模式:直接上传
|
||||
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)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
} else {
|
||||
// 创建模式:本地预览,保存时上传
|
||||
setPendingFile(file)
|
||||
setAvatarUrl(URL.createObjectURL(file))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,21 +251,34 @@ function EditExpertDialog({
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('姓名不能为空')
|
||||
return
|
||||
if (!name.trim()) { toast.error('姓名不能为空'); return }
|
||||
if (isCreate) {
|
||||
if (!phone.trim()) { toast.error('手机号不能为空'); return }
|
||||
if (password.length < 6) { toast.error('密码至少 6 位'); 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('专家信息已更新')
|
||||
if (isCreate) {
|
||||
const res = await api.adminCreateExpert({
|
||||
name: name.trim(), phone: phone.trim(), password,
|
||||
category, title: title.trim(), bio: bio.trim(),
|
||||
expertise: expertise.trim(), status: 'active',
|
||||
})
|
||||
// 创建成功后上传头像
|
||||
if (pendingFile && res.expert.id) {
|
||||
try {
|
||||
await api.adminUploadExpertAvatar(res.expert.id, pendingFile)
|
||||
} catch { /* 头像失败不影响创建 */ }
|
||||
}
|
||||
toast.success('专家创建成功')
|
||||
} else {
|
||||
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 : '保存失败')
|
||||
@@ -275,14 +291,14 @@ function EditExpertDialog({
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑专家信息</DialogTitle>
|
||||
<DialogDescription>修改专家资料、上传头像</DialogDescription>
|
||||
<DialogTitle>{isCreate ? '新增专家' : '编辑专家信息'}</DialogTitle>
|
||||
<DialogDescription>{isCreate ? '创建专家账号,填写基本信息和登录密码' : '修改专家资料、上传头像'}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto py-2 scrollbar-none">
|
||||
{/* 头像上传区 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ExpertAvatar name={name || expert.name} avatarUrl={avatarUrl} size="2xl" shape="rect" />
|
||||
<ExpertAvatar name={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>
|
||||
@@ -324,6 +340,20 @@ function EditExpertDialog({
|
||||
<Input id="edit-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="专家姓名" />
|
||||
</div>
|
||||
|
||||
{/* 手机号 + 密码(仅创建模式) */}
|
||||
{isCreate && (
|
||||
<>
|
||||
<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>
|
||||
@@ -361,123 +391,8 @@ function EditExpertDialog({
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 新增专家弹窗:含登录账号 + 基本信息 */
|
||||
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 scrollbar-none">
|
||||
{/* 姓名 */}
|
||||
<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" />}
|
||||
创建
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : isCreate ? <UserPlus className="h-3.5 w-3.5" /> : null}
|
||||
{isCreate ? '创建' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user