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:
selfrelease
2026-08-29 23:57:26 +08:00
co-authored by Devin
commit 5c21e8fcd0
101 changed files with 25273 additions and 0 deletions
@@ -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>
)
}
+110
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
/** 专家工作台页面集合 barrel 导出 */
export { WorkbenchPendingServices } from './WorkbenchPendingServices'
export { WorkbenchPending } from './WorkbenchPending'
export { WorkbenchReplied } from './WorkbenchReplied'
export { WorkbenchUsers } from './WorkbenchUsers'
+202
View File
@@ -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>
)
}