feat: BossAi 老板AI助手初始版本
包含功能: - 法务参谋/经营决策/生活助理/情绪中心四大AI对话模块 - 专家咨询系统(用户选定→专家确认→咨询→回复→评价) - 角色登录(用户/专家/管理员)+ ProtectedRoute 权限控制 - 管理后台(用户管理/专家管理/AI使用统计/专家活跃度) - 专家工作台(待确认服务/待回复咨询/已回复咨询/服务中用户) - 服务关系取消(用户和专家均可取消,需自己确认) - 全页面移动端响应式适配 - 知识库RAG检索 - 对话历史(普通/保密模式) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { MessageSquare, Send } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Markdown } from '@/components/common/Markdown'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Consultation } from '@/types'
|
||||
import { useWorkbenchData, WorkbenchShell, ReplyDialog, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 待回复咨询:用户发起的咨询,专家在此回复 */
|
||||
export function WorkbenchPending() {
|
||||
const { expert, consultations, loading, error, loadData } = useWorkbenchData()
|
||||
const [replyTarget, setReplyTarget] = useState<Consultation | null>(null)
|
||||
|
||||
const pendingConsultations = consultations.filter(c => c.status === 'pending')
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="待回复咨询" description="用户发起的咨询,在此回复">
|
||||
{pendingConsultations.length === 0 ? (
|
||||
<EmptyState title="暂无待回复咨询" description="用户发起咨询后会显示在这里" icon={<MessageSquare className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pendingConsultations.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{c.topic}</p>
|
||||
<Badge variant="destructive" className="shrink-0 text-xs">待回复</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">用户:{maskPhone(c.user_phone || '')}</p>
|
||||
{c.question && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<Markdown content={c.question} />
|
||||
</div>
|
||||
)}
|
||||
{c.context && (
|
||||
<div className="mt-2 rounded-md bg-muted/30 p-2 text-xs text-muted-foreground">
|
||||
<p className="mb-1 font-medium">AI 对话上下文:</p>
|
||||
<Markdown content={c.context} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">创建时间:{c.created_at}</p>
|
||||
</div>
|
||||
<Button size="sm" className="shrink-0" onClick={() => setReplyTarget(c)}>
|
||||
<Send className="h-3.5 w-3.5" /> 回复
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReplyDialog target={replyTarget} onClose={() => setReplyTarget(null)} onSuccess={loadData} />
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '@/lib/api'
|
||||
import { useWorkbenchData, WorkbenchShell, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 待确认服务:用户选定专家后,专家确认接单 */
|
||||
export function WorkbenchPendingServices() {
|
||||
const { expert, services, loading, error, loadData } = useWorkbenchData()
|
||||
const [accepting, setAccepting] = useState<number | null>(null)
|
||||
|
||||
const pendingServices = services.filter(s => s.status === 'pending')
|
||||
|
||||
const handleAccept = async (serviceId: number) => {
|
||||
setAccepting(serviceId)
|
||||
try {
|
||||
await api.acceptService(serviceId)
|
||||
toast.success('已确认服务关系')
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '确认失败')
|
||||
} finally {
|
||||
setAccepting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="待确认服务" description="用户选定你后,在此确认接单">
|
||||
{pendingServices.length === 0 ? (
|
||||
<EmptyState title="暂无待确认的服务" description="用户选定你后会显示在这里" icon={<Check className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pendingServices.map((s) => (
|
||||
<Card key={s.service_id}>
|
||||
<CardContent className="flex items-center justify-between gap-2 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">用户:{maskPhone(s.user_phone || '')}</p>
|
||||
<p className="text-xs text-muted-foreground">请求时间:{s.created_at}</p>
|
||||
</div>
|
||||
<Button size="sm" className="shrink-0" onClick={() => handleAccept(s.service_id)} disabled={accepting === s.service_id}>
|
||||
{accepting === s.service_id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
确认接单
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react'
|
||||
import { Star, Eye } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { Consultation } from '@/types'
|
||||
import { useWorkbenchData, WorkbenchShell, ConsultationDetailDialog, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 已回复咨询:查看已回复过的咨询详情 */
|
||||
export function WorkbenchReplied() {
|
||||
const { expert, consultations, loading, error } = useWorkbenchData()
|
||||
const [detailTarget, setDetailTarget] = useState<Consultation | null>(null)
|
||||
|
||||
const repliedConsultations = consultations.filter(c => c.status === 'replied')
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="已回复咨询" description="回复过的咨询,点击查看详情">
|
||||
{repliedConsultations.length === 0 ? (
|
||||
<EmptyState title="暂无已回复咨询" description="回复过的咨询会显示在这里" icon={<Star className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{repliedConsultations.map((c) => (
|
||||
<Card key={c.id} className="cursor-pointer transition hover:bg-muted/30" onClick={() => setDetailTarget(c)}>
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{c.topic}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">用户:{maskPhone(c.user_phone || '')}</p>
|
||||
</div>
|
||||
<Eye className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{c.reply}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">回复时间:{c.replied_at}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConsultationDetailDialog target={detailTarget} onClose={() => setDetailTarget(null)} />
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Briefcase, Loader2, XCircle } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { Consultation, ExpertService } from '@/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useWorkbenchData, WorkbenchShell, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 服务中用户:已确认服务关系的用户列表,可取消服务 */
|
||||
export function WorkbenchUsers() {
|
||||
const { expert, services, consultations, loading, error, loadData } = useWorkbenchData()
|
||||
const [cancelService, setCancelService] = useState<ExpertService | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
const activeServices = services.filter(s => s.status === 'active')
|
||||
|
||||
/** 按服务用户分组咨询 */
|
||||
const consultationsByUser = useMemo(() => {
|
||||
const map = new Map<string, Consultation[]>()
|
||||
for (const c of consultations) {
|
||||
const phone = c.user_phone || 'unknown'
|
||||
if (!map.has(phone)) {
|
||||
map.set(phone, [])
|
||||
}
|
||||
map.get(phone)!.push(c)
|
||||
}
|
||||
return map
|
||||
}, [consultations])
|
||||
|
||||
/** 确认取消服务关系 */
|
||||
const handleCancel = async () => {
|
||||
if (!cancelService) return
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.cancelExpertService(cancelService.service_id)
|
||||
toast.success('服务关系已取消')
|
||||
setCancelService(null)
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '取消失败')
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="服务中用户" description="已确认服务关系的用户">
|
||||
{activeServices.length === 0 ? (
|
||||
<EmptyState title="暂无服务中用户" description="确认接单的用户会显示在这里" icon={<Briefcase className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeServices.map((s) => {
|
||||
const userConsultations = consultationsByUser.get(s.user_phone || '') || []
|
||||
return (
|
||||
<Card key={s.service_id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">用户:{maskPhone(s.user_phone || '')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
签约时间:{s.signed_at}
|
||||
{userConsultations.length > 0 && ` · ${userConsultations.length} 条咨询`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="shrink-0">服务中</Badge>
|
||||
</div>
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-rose-600 hover:bg-rose-50"
|
||||
onClick={() => setCancelService(s)}
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" /> 取消服务
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 取消服务确认弹窗 */}
|
||||
<Dialog open={!!cancelService} onOpenChange={(o) => !o && setCancelService(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>取消服务关系</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认取消与用户「{cancelService ? maskPhone(cancelService.user_phone || '') : ''}」的服务关系?取消后不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelService(null)}>再想想</Button>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
|
||||
{cancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
|
||||
确认取消
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 专家工作台页面集合 barrel 导出 */
|
||||
export { WorkbenchPendingServices } from './WorkbenchPendingServices'
|
||||
export { WorkbenchPending } from './WorkbenchPending'
|
||||
export { WorkbenchReplied } from './WorkbenchReplied'
|
||||
export { WorkbenchUsers } from './WorkbenchUsers'
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Briefcase, Loader2, Send, MessageSquare, FileText, Check } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Markdown } from '@/components/common/Markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Expert, ExpertService, Consultation } from '@/types'
|
||||
|
||||
/** 脱敏手机号 */
|
||||
export function maskPhone(phone: string): string {
|
||||
if (!phone || phone === 'unknown') return '未知用户'
|
||||
if (phone.length >= 8) return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
return phone
|
||||
}
|
||||
|
||||
/** 专家工作台数据加载 hook */
|
||||
export function useWorkbenchData() {
|
||||
const [expert, setExpert] = useState<Expert | null>(null)
|
||||
const [services, setServices] = useState<ExpertService[]>([])
|
||||
const [consultations, setConsultations] = useState<Consultation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.expertWorkbench()
|
||||
setExpert(res.expert)
|
||||
setServices(res.services)
|
||||
setConsultations(res.consultations)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
return { expert, services, consultations, loading, error, loadData }
|
||||
}
|
||||
|
||||
/** 回复弹窗 */
|
||||
export function ReplyDialog({
|
||||
target,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
target: Consultation | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
const handleReply = async () => {
|
||||
if (!target || !replyText.trim()) return
|
||||
setReplying(true)
|
||||
try {
|
||||
await api.replyConsultation(target.id, replyText.trim())
|
||||
toast.success('回复已发送')
|
||||
onClose()
|
||||
setReplyText('')
|
||||
onSuccess()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '回复失败')
|
||||
} finally {
|
||||
setReplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>回复咨询</DialogTitle>
|
||||
<DialogDescription>{target?.topic}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="输入你的专业回复…"
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleReply} disabled={replying || !replyText.trim()}>
|
||||
{replying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
发送回复
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** 咨询详情弹窗 */
|
||||
export function ConsultationDetailDialog({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: Consultation | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>咨询详情</DialogTitle>
|
||||
<DialogDescription>{target?.topic}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
|
||||
{/* 用户问题 */}
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<MessageSquare className="h-3.5 w-3.5" /> 用户问题
|
||||
</p>
|
||||
<div className="rounded-md bg-muted/30 p-3 text-sm">
|
||||
<Markdown content={target?.question || ''} />
|
||||
</div>
|
||||
</div>
|
||||
{/* AI 上下文 */}
|
||||
{target?.context && (
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" /> AI 对话上下文
|
||||
</p>
|
||||
<div className="rounded-md bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<Markdown content={target.context} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 专家回复 */}
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5 text-emerald-500" /> 专家回复
|
||||
</p>
|
||||
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-sm">
|
||||
<Markdown content={target?.reply || ''} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">回复时间:{target?.replied_at}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** 工作台页面外壳:处理 loading/error/非专家的通用逻辑 */
|
||||
export function WorkbenchShell({
|
||||
expert,
|
||||
loading,
|
||||
error,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
expert: Expert | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title={title} description={description}>
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout title={title} description={description}>
|
||||
<EmptyState title="无法访问" description={error} icon={<Briefcase className="h-8 w-8" />} />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
if (!expert) {
|
||||
return (
|
||||
<PageLayout title={title} description={description}>
|
||||
<EmptyState title="您不是注册专家" description="请联系管理员开通专家账号" icon={<Briefcase className="h-8 w-8" />} />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PageLayout title={title} description={description || `${expert.name} · ${expert.title}`}>
|
||||
{children}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user