- 后端 /experts 返回 connected 字段(基于 expert_consultations 表) - 前端卡片显示"已联系"徽章 - 未联系时按钮为"建立联系",调用 selectExpert API - 已联系后按钮变为"咨询",可跳转对应场景 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
/** 专家库:浏览所有专家,选择后跳转到对应智囊场景咨询
|
|
* 卡片样式参考管理端专家管理:长方形头像 + 姓名/评分 + 简介 + 专长标签
|
|
* 已建立服务联系的可直接咨询,未建立的需先建立联系
|
|
*/
|
|
import { useEffect, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Users, Star, ChevronRight, Briefcase, Link2, CheckCircle2 } from 'lucide-react'
|
|
import { PageLayout } from '@/components/common/PageLayout'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { LoadingState } from '@/components/common/LoadingState'
|
|
import { ErrorState } from '@/components/common/ErrorState'
|
|
import { EmptyState } from '@/components/common/EmptyState'
|
|
import { ExpertAvatar } from '@/components/common/ExpertAvatar'
|
|
import { api } from '@/lib/api'
|
|
import { SCENE_LABELS, cn } from '@/lib/utils'
|
|
import type { Expert, Scene } from '@/types'
|
|
|
|
const CATEGORY_FILTERS: { value: Scene | 'all'; label: string }[] = [
|
|
{ value: 'all', label: '全部' },
|
|
{ value: 'legal', label: '法务' },
|
|
{ value: 'decision', label: '决策' },
|
|
{ value: 'life', label: '生活' },
|
|
{ value: 'emotion', label: '情绪' },
|
|
]
|
|
|
|
export function Experts() {
|
|
const navigate = useNavigate()
|
|
const [experts, setExperts] = useState<Expert[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [filter, setFilter] = useState<Scene | 'all'>('all')
|
|
const [connecting, setConnecting] = useState<number | null>(null)
|
|
|
|
const load = () => {
|
|
setLoading(true); setError('')
|
|
api.listExperts(filter === 'all' ? undefined : filter)
|
|
.then((r) => { setExperts(r.results); setLoading(false) })
|
|
.catch((e) => { setError(e.message); setLoading(false) })
|
|
}
|
|
|
|
useEffect(load, [filter])
|
|
|
|
/** 建立服务联系 */
|
|
const handleConnect = async (ex: Expert) => {
|
|
setConnecting(ex.id)
|
|
try {
|
|
await api.selectExpert(ex.id)
|
|
// 更新本地状态
|
|
setExperts((prev) => prev.map((e) => e.id === ex.id ? { ...e, connected: true } : e))
|
|
} catch (e: any) {
|
|
setError(e.message || '建立联系失败')
|
|
} finally {
|
|
setConnecting(null)
|
|
}
|
|
}
|
|
|
|
if (loading) return <LoadingState label="加载专家库..." />
|
|
if (error) return <ErrorState message={error} onRetry={load} />
|
|
|
|
return (
|
|
<PageLayout title="专家库" description={`共 ${experts.length} 位专家可咨询`}>
|
|
{/* 分类筛选 */}
|
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
|
{CATEGORY_FILTERS.map((f) => (
|
|
<button
|
|
key={f.value}
|
|
onClick={() => setFilter(f.value)}
|
|
className={cn(
|
|
'shrink-0 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
filter === f.value
|
|
? 'border-primary bg-primary/10 text-primary font-medium'
|
|
: 'border-border bg-card text-muted-foreground hover:bg-accent',
|
|
)}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{experts.length === 0 ? (
|
|
<EmptyState
|
|
title="暂无专家"
|
|
description="该分类下还没有可用专家"
|
|
icon={Users}
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{experts.map((ex) => {
|
|
const stars = (ex.rate / 100).toFixed(1)
|
|
const isConnected = !!ex.connected
|
|
return (
|
|
<Card key={ex.id} className="transition-shadow hover:shadow-md">
|
|
<CardContent className="p-4 sm:p-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="truncate font-medium text-foreground">{ex.name}</h3>
|
|
{isConnected && (
|
|
<Badge variant="outline" className="border-emerald-300 text-emerald-600 text-xs">
|
|
<CheckCircle2 className="mr-0.5 h-3 w-3" />已联系
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">{ex.title}</p>
|
|
{ex.rating_count > 0 && (
|
|
<div className="mt-1 flex items-center gap-1 text-xs">
|
|
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
|
<span className="font-medium">{stars}</span>
|
|
<span className="text-muted-foreground">({ex.rating_count}人评价)</span>
|
|
</div>
|
|
)}
|
|
{/* 简介 */}
|
|
{ex.bio && (
|
|
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{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">
|
|
{SCENE_LABELS[ex.category] || ex.category}
|
|
</Badge>
|
|
{isConnected ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => navigate(`/${ex.category}`, { state: { expertId: ex.id } })}
|
|
>
|
|
<Briefcase className="mr-1 h-3.5 w-3.5" /> 咨询
|
|
<ChevronRight className="ml-0.5 h-3 w-3" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={connecting === ex.id}
|
|
onClick={() => handleConnect(ex)}
|
|
>
|
|
<Link2 className="mr-1 h-3.5 w-3.5" />
|
|
{connecting === ex.id ? '建立中...' : '建立联系'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</PageLayout>
|
|
)
|
|
}
|