refactor: 专家创建/编辑合并为ExpertFormDialog,创建模式支持头像上传
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

This commit is contained in:
selfrelease
2026-08-31 16:13:21 +08:00
parent ee8692a5d5
commit 0a5b593cb4
+80 -165
View File
@@ -165,20 +165,12 @@ export function AdminExperts() {
</div> </div>
)} )}
{/* 编辑弹窗 */} {/* 编辑/新增弹窗 */}
{editing && ( {(editing || creating) && (
<EditExpertDialog <ExpertFormDialog
expert={editing} expert={editing}
onClose={() => setEditing(null)} onClose={() => { setEditing(null); setCreating(false) }}
onSuccess={() => { loadExperts(); setEditing(null) }} onSuccess={() => { loadExperts(); setEditing(null); setCreating(false) }}
/>
)}
{/* 新增专家弹窗 */}
{creating && (
<CreateExpertDialog
onClose={() => setCreating(false)}
onSuccess={() => { loadExperts(); setCreating(false) }}
/> />
)} )}
@@ -204,40 +196,51 @@ export function AdminExperts() {
) )
} }
/** 编辑专家信息弹窗:含头像上传 + 表单字段 */ /** 专家信息弹窗:创建/编辑共用,含头像上传 + 表单字段 */
function EditExpertDialog({ function ExpertFormDialog({
expert, expert,
onClose, onClose,
onSuccess, onSuccess,
}: { }: {
expert: Expert expert: Expert | null // null = 创建模式
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
}) { }) {
const [name, setName] = useState(expert.name) const isCreate = !expert
const [category, setCategory] = useState(expert.category) const [name, setName] = useState(expert?.name || '')
const [title, setTitle] = useState(expert.title) const [phone, setPhone] = useState('')
const [bio, setBio] = useState(expert.bio) const [password, setPassword] = useState('')
const [expertise, setExpertise] = useState(expert.expertise) const [category, setCategory] = useState<Expert['category']>(expert?.category || 'legal')
const [avatarUrl, setAvatarUrl] = useState(expert.avatar_url) 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 [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [pendingFile, setPendingFile] = useState<File | null>(null)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
setUploading(true) if (expert) {
try { // 编辑模式:直接上传
const res = await api.adminUploadExpertAvatar(expert.id, file) setUploading(true)
setAvatarUrl(res.avatar_url) try {
toast.success('头像上传成功') const res = await api.adminUploadExpertAvatar(expert.id, file)
} catch (err) { setAvatarUrl(res.avatar_url)
toast.error(err instanceof Error ? err.message : '上传失败') toast.success('头像上传成功')
} finally { } catch (err) {
setUploading(false) toast.error(err instanceof Error ? err.message : '上传失败')
// 清空 input 允许重复选同一文件 } finally {
if (fileInputRef.current) fileInputRef.current.value = '' setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
} else {
// 创建模式:本地预览,保存时上传
setPendingFile(file)
setAvatarUrl(URL.createObjectURL(file))
} }
} }
@@ -248,21 +251,34 @@ function EditExpertDialog({
} }
const handleSave = async () => { const handleSave = async () => {
if (!name.trim()) { if (!name.trim()) { toast.error('姓名不能为空'); return }
toast.error('姓名不能为空') if (isCreate) {
return if (!phone.trim()) { toast.error('手机号不能为空'); return }
if (password.length < 6) { toast.error('密码至少 6 位'); return }
} }
setSaving(true) setSaving(true)
try { try {
await api.adminUpdateExpert(expert.id, { if (isCreate) {
name: name.trim(), const res = await api.adminCreateExpert({
category, name: name.trim(), phone: phone.trim(), password,
title: title.trim(), category, title: title.trim(), bio: bio.trim(),
bio: bio.trim(), expertise: expertise.trim(), status: 'active',
expertise: expertise.trim(), })
avatar_url: avatarUrl, // 创建成功后上传头像
}) if (pendingFile && res.expert.id) {
toast.success('专家信息已更新') 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() onSuccess()
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败') toast.error(err instanceof Error ? err.message : '保存失败')
@@ -275,14 +291,14 @@ function EditExpertDialog({
<Dialog open onOpenChange={(o) => !o && onClose()}> <Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>编辑专家信息</DialogTitle> <DialogTitle>{isCreate ? '新增专家' : '编辑专家信息'}</DialogTitle>
<DialogDescription>修改专家资料、上传头像</DialogDescription> <DialogDescription>{isCreate ? '创建专家账号,填写基本信息和登录密码' : '修改专家资料、上传头像'}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto py-2 scrollbar-none"> <div className="max-h-[70vh] space-y-4 overflow-y-auto py-2 scrollbar-none">
{/* 头像上传区 */} {/* 头像上传区 */}
<div className="flex items-center gap-4"> <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"> <div className="space-y-2">
<p className="text-sm font-medium">专家头像</p> <p className="text-sm font-medium">专家头像</p>
<p className="text-xs text-muted-foreground">支持 JPG / PNG / WebP,最大 5MB</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="专家姓名" /> <Input id="edit-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="专家姓名" />
</div> </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"> <div className="space-y-1.5">
<Label>分类</Label> <Label>分类</Label>
@@ -361,123 +391,8 @@ function EditExpertDialog({
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button> <Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSave} disabled={saving}> <Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null} {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>
</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" />}
创建
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>