feat: 专家库显示服务联系状态,未联系需先建立才能咨询
- 后端 /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>
This commit is contained in:
@@ -1282,9 +1282,32 @@ class RatingRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/experts")
|
@app.get("/experts")
|
||||||
def list_experts(category: str | None = None):
|
def list_experts(category: str | None = None, authorization: str | None = Header(default=None)):
|
||||||
"""列出专家,支持按分类筛选(legal/decision/emotion/life)。"""
|
"""列出专家,支持按分类筛选(legal/decision/emotion/life)。
|
||||||
return {"results": expert_store.list_experts(category=category, status="active")}
|
登录后额外返回 connected 字段(是否已建立服务联系)。
|
||||||
|
"""
|
||||||
|
results = expert_store.list_experts(category=category, status="active")
|
||||||
|
# 查询当前用户已建立联系的专家ID集合
|
||||||
|
phone = get_current_phone(authorization)
|
||||||
|
if phone and results:
|
||||||
|
conn = _matter_dsn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
expert_ids = [r["id"] for r in results]
|
||||||
|
cur.execute(
|
||||||
|
"SELECT DISTINCT expert_id FROM expert_consultations "
|
||||||
|
"WHERE user_phone = %s AND expert_id = ANY(%s)",
|
||||||
|
(phone, expert_ids),
|
||||||
|
)
|
||||||
|
connected_ids = {r[0] for r in cur.fetchall()}
|
||||||
|
for r in results:
|
||||||
|
r["connected"] = r["id"] in connected_ids
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
for r in results:
|
||||||
|
r["connected"] = False
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/experts/{expert_id}")
|
@app.get("/experts/{expert_id}")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** 专家库:浏览所有专家,选择后跳转到对应智囊场景咨询
|
/** 专家库:浏览所有专家,选择后跳转到对应智囊场景咨询
|
||||||
* 卡片样式参考管理端专家管理:长方形头像 + 姓名/评分 + 简介 + 专长标签
|
* 卡片样式参考管理端专家管理:长方形头像 + 姓名/评分 + 简介 + 专长标签
|
||||||
|
* 已建立服务联系的可直接咨询,未建立的需先建立联系
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Users, Star, ChevronRight, Briefcase } from 'lucide-react'
|
import { Users, Star, ChevronRight, Briefcase, Link2, CheckCircle2 } from 'lucide-react'
|
||||||
import { PageLayout } from '@/components/common/PageLayout'
|
import { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -30,6 +31,7 @@ export function Experts() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [filter, setFilter] = useState<Scene | 'all'>('all')
|
const [filter, setFilter] = useState<Scene | 'all'>('all')
|
||||||
|
const [connecting, setConnecting] = useState<number | null>(null)
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true); setError('')
|
setLoading(true); setError('')
|
||||||
@@ -40,6 +42,20 @@ export function Experts() {
|
|||||||
|
|
||||||
useEffect(load, [filter])
|
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 (loading) return <LoadingState label="加载专家库..." />
|
||||||
if (error) return <ErrorState message={error} onRetry={load} />
|
if (error) return <ErrorState message={error} onRetry={load} />
|
||||||
|
|
||||||
@@ -73,6 +89,7 @@ export function Experts() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{experts.map((ex) => {
|
{experts.map((ex) => {
|
||||||
const stars = (ex.rate / 100).toFixed(1)
|
const stars = (ex.rate / 100).toFixed(1)
|
||||||
|
const isConnected = !!ex.connected
|
||||||
return (
|
return (
|
||||||
<Card key={ex.id} className="transition-shadow hover:shadow-md">
|
<Card key={ex.id} className="transition-shadow hover:shadow-md">
|
||||||
<CardContent className="p-4 sm:p-5">
|
<CardContent className="p-4 sm:p-5">
|
||||||
@@ -88,6 +105,11 @@ export function Experts() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="truncate font-medium text-foreground">{ex.name}</h3>
|
<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>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">{ex.title}</p>
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">{ex.title}</p>
|
||||||
{ex.rating_count > 0 && (
|
{ex.rating_count > 0 && (
|
||||||
@@ -112,11 +134,12 @@ export function Experts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部:分类 + 咨询按钮 */}
|
{/* 底部:分类 + 咨询/建立联系按钮 */}
|
||||||
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
|
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{SCENE_LABELS[ex.category] || ex.category}
|
{SCENE_LABELS[ex.category] || ex.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{isConnected ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(`/${ex.category}`, { state: { expertId: ex.id } })}
|
onClick={() => navigate(`/${ex.category}`, { state: { expertId: ex.id } })}
|
||||||
@@ -124,6 +147,17 @@ export function Experts() {
|
|||||||
<Briefcase className="mr-1 h-3.5 w-3.5" /> 咨询
|
<Briefcase className="mr-1 h-3.5 w-3.5" /> 咨询
|
||||||
<ChevronRight className="ml-0.5 h-3 w-3" />
|
<ChevronRight className="ml-0.5 h-3 w-3" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface Expert {
|
|||||||
status: 'active' | 'inactive'
|
status: 'active' | 'inactive'
|
||||||
created_at?: string
|
created_at?: string
|
||||||
ratings?: ExpertRating[]
|
ratings?: ExpertRating[]
|
||||||
|
connected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 专家评价 */
|
/** 专家评价 */
|
||||||
|
|||||||
Reference in New Issue
Block a user