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,447 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
History,
|
||||
Lock,
|
||||
Trash2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HistorySession, HistoryMessage, SessionMode } from '@/types'
|
||||
|
||||
/** 模式标签配置 */
|
||||
const MODE_CONFIG: Record<SessionMode, { label: string; icon: typeof History; color: string }> = {
|
||||
normal: { label: '普通模式', icon: MessageSquare, color: 'bg-blue-100 text-blue-700' },
|
||||
confidential: { label: '保密模式', icon: Lock, color: 'bg-amber-100 text-amber-700' },
|
||||
ephemeral: { label: '阅后即焚', icon: Shield, color: 'bg-rose-100 text-rose-700' },
|
||||
}
|
||||
|
||||
/** 风险等级颜色 */
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
green: 'bg-green-100 text-green-700',
|
||||
amber: 'bg-yellow-100 text-yellow-700',
|
||||
orange: 'bg-orange-100 text-orange-700',
|
||||
red: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
/** 场景中文映射 */
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
legal: '法务参谋',
|
||||
decision: '经营决策',
|
||||
life: '生活助理',
|
||||
emotion: '情绪中心',
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function ChatHistory() {
|
||||
const [sessions, setSessions] = useState<HistorySession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'normal' | 'confidential'>('all')
|
||||
|
||||
// 当前查看的会话
|
||||
const [currentSession, setCurrentSession] = useState<HistorySession | null>(null)
|
||||
const [messages, setMessages] = useState<HistoryMessage[]>([])
|
||||
const [msgLoading, setMsgLoading] = useState(false)
|
||||
const [msgError, setMsgError] = useState<string | null>(null)
|
||||
const [needVerify, setNeedVerify] = useState(false)
|
||||
|
||||
// 保密模式密码验证
|
||||
const [verifyPassword, setVerifyPassword] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [deleteTarget, setDeleteTarget] = useState<HistorySession | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const mode = modeFilter === 'all' ? undefined : modeFilter
|
||||
const res = await api.listHistorySessions(mode)
|
||||
setSessions(res.results)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [modeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [loadSessions])
|
||||
|
||||
/** 查看会话历史 */
|
||||
const handleView = useCallback(async (session: HistorySession) => {
|
||||
setCurrentSession(session)
|
||||
setMessages([])
|
||||
setMsgError(null)
|
||||
setNeedVerify(false)
|
||||
setVerifyPassword('')
|
||||
setMsgLoading(true)
|
||||
try {
|
||||
const res = await api.getHistoryMessages(session.session_id)
|
||||
if (res.need_verify) {
|
||||
setNeedVerify(true)
|
||||
} else {
|
||||
setMessages(res.messages || [])
|
||||
}
|
||||
} catch (err) {
|
||||
setMsgError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setMsgLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 保密模式密码验证 */
|
||||
const handleVerify = useCallback(async () => {
|
||||
if (!currentSession || !verifyPassword) return
|
||||
setVerifying(true)
|
||||
setMsgError(null)
|
||||
try {
|
||||
const res = await api.verifyHistory(currentSession.session_id, verifyPassword)
|
||||
setMessages(res.messages)
|
||||
setNeedVerify(false)
|
||||
toast.success('验证通过,已解密历史消息')
|
||||
} catch (err) {
|
||||
setMsgError(err instanceof Error ? err.message : '验证失败')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}, [currentSession, verifyPassword])
|
||||
|
||||
/** 删除会话历史 */
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.deleteHistory(deleteTarget.session_id)
|
||||
toast.success('已删除该会话历史')
|
||||
setDeleteTarget(null)
|
||||
// 如果删除的是当前查看的会话,返回列表
|
||||
if (currentSession?.session_id === deleteTarget.session_id) {
|
||||
setCurrentSession(null)
|
||||
}
|
||||
loadSessions()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除失败')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTarget, currentSession, loadSessions])
|
||||
|
||||
/** 返回列表 */
|
||||
const handleBack = useCallback(() => {
|
||||
setCurrentSession(null)
|
||||
setMessages([])
|
||||
setNeedVerify(false)
|
||||
setVerifyPassword('')
|
||||
}, [])
|
||||
|
||||
// ===== 会话详情视图 =====
|
||||
if (currentSession) {
|
||||
const modeCfg = MODE_CONFIG[currentSession.mode]
|
||||
return (
|
||||
<PageLayout
|
||||
title="对话历史"
|
||||
description="查看和管理历史对话记录"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(currentSession)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex items-center gap-3 border-b pb-3">
|
||||
<modeCfg.icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{modeCfg.label}</span>
|
||||
{currentSession.scene && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{SCENE_LABELS[currentSession.scene] || currentSession.scene}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
|
||||
{' · '}{currentSession.message_count} 条消息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保密模式密码验证 */
|
||||
needVerify ? (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<div className="mb-4 flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
||||
<Lock className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">保密模式历史</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
该会话为加密存储,请输入登录密码验证后查看
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="verify-pwd" className="text-xs">登录密码</Label>
|
||||
<Input
|
||||
id="verify-pwd"
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleVerify()}
|
||||
placeholder="请输入登录密码"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{msgError && <p className="text-xs text-destructive">{msgError}</p>}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleVerify}
|
||||
disabled={verifying || !verifyPassword}
|
||||
>
|
||||
{verifying ? (
|
||||
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />验证中</>
|
||||
) : (
|
||||
'验证并查看'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : msgLoading ? (
|
||||
<LoadingState label="加载历史消息..." />
|
||||
) : msgError ? (
|
||||
<ErrorState message={msgError} onRetry={() => handleView(currentSession)} />
|
||||
) : messages.length === 0 ? (
|
||||
<EmptyState icon={<MessageSquare className="h-8 w-8" />} title="暂无消息" description="该会话没有历史记录" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 会话列表视图 =====
|
||||
return (
|
||||
<PageLayout
|
||||
title="对话历史"
|
||||
description="普通模式可直接查看,保密模式需密码验证,阅后即焚不落库"
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-md border bg-card p-1">
|
||||
{(['all', 'normal', 'confidential'] as const).map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={modeFilter === m ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setModeFilter(m)}
|
||||
>
|
||||
{m === 'all' ? '全部' : m === 'normal' ? '普通' : '保密'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingState label="加载历史会话..." />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadSessions} />
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<History className="h-8 w-8" />}
|
||||
title="暂无历史会话"
|
||||
description="普通模式和保密模式的对话会保存在这里,阅后即焚不落库"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const modeCfg = MODE_CONFIG[session.mode]
|
||||
return (
|
||||
<Card
|
||||
key={session.session_id}
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={() => handleView(session)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', modeCfg.color)}>
|
||||
<modeCfg.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{modeCfg.label}</span>
|
||||
{session.scene && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{SCENE_LABELS[session.scene] || session.scene}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.message_count} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{formatTime(session.last_message_at)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单条消息气泡 */
|
||||
function MessageBubble({ message }: { message: HistoryMessage }) {
|
||||
const isUser = message.role === 'user'
|
||||
const riskLevel = message.risk?.level
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('max-w-[80%] rounded-lg p-3', isUser ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
|
||||
{/* 消息正文 */}
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
|
||||
{/* AI 回复的风险卡信息 */}
|
||||
{!isUser && riskLevel && (
|
||||
<div className="mt-2 space-y-1 border-t border-border/30 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn('text-xs', RISK_COLORS[riskLevel])}>
|
||||
{riskLevel === 'green' ? '低风险' : riskLevel === 'amber' ? '需补证' : riskLevel === 'orange' ? '建议复核' : '高风险'}
|
||||
</Badge>
|
||||
{message.risk?.escalation && (
|
||||
<Badge variant="destructive" className="text-xs">建议升级</Badge>
|
||||
)}
|
||||
{message.meta?.inference_location && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
{message.meta.inference_location === 'cloud' ? '云端' : '本地'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{message.risk?.actions && message.risk.actions.length > 0 && (
|
||||
<ul className="text-xs opacity-80">
|
||||
{message.risk.actions.map((a, i) => (
|
||||
<li key={i}>• {a}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间戳 */}
|
||||
<p className={cn('mt-1 text-[10px]', isUser ? 'text-primary-foreground/60' : 'text-muted-foreground')}>
|
||||
{formatTime(message.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 删除确认弹窗 */
|
||||
function DeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
deleting,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
deleting: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除对话历史</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除该会话的所有历史消息吗?此操作不可恢复。
|
||||
{open && ' 保密模式的加密数据将一并清除。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={deleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={deleting}>
|
||||
{deleting ? (
|
||||
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />删除中</>
|
||||
) : (
|
||||
'确认删除'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Scale,
|
||||
TrendingUp,
|
||||
Heart,
|
||||
Smile,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
interface HealthState {
|
||||
status: 'ok' | 'loading' | 'error'
|
||||
service?: string
|
||||
}
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ to: '/legal', label: '法务参谋', desc: '劳动用工、合同审查、催款违约', icon: Scale },
|
||||
{ to: '/decisions', label: '经营决策', desc: '项目承接、扩张收缩、融资合伙', icon: TrendingUp },
|
||||
{ to: '/life', label: '生活助理', desc: '家庭、健康、出差、重要事项', icon: Heart },
|
||||
{ to: '/emotion', label: '情绪中心', desc: '冷静分析、陪伴疏导、强力推动', icon: Smile },
|
||||
]
|
||||
|
||||
/** 驾驶舱:今日概览 + 快速入口 + 系统状态 */
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const [health, setHealth] = useState<HealthState>({ status: 'loading' })
|
||||
|
||||
const checkHealth = async () => {
|
||||
setHealth({ status: 'loading' })
|
||||
try {
|
||||
const res = await api.health()
|
||||
setHealth({ status: 'ok', service: res.service })
|
||||
} catch {
|
||||
setHealth({ status: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="驾驶舱"
|
||||
description="今日风险、待办与快速入口。老板敢问,系统敢忘。"
|
||||
>
|
||||
{/* 系统状态条 */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-emerald-100">
|
||||
<ShieldCheck className="h-5 w-5 text-emerald-600" aria-hidden />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">系统状态</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{health.status === 'loading' && '正在检查后端服务…'}
|
||||
{health.status === 'ok' && `后端服务正常 · ${health.service}`}
|
||||
{health.status === 'error' && '后端服务不可达,请确认 API 已启动'}
|
||||
</p>
|
||||
</div>
|
||||
{health.status === 'error' && (
|
||||
<Button variant="outline" size="sm" onClick={checkHealth}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 今日最该做的一件事 */}
|
||||
<Card className="border-l-4 border-l-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-amber-500" aria-hidden />
|
||||
今天最该做的一件事
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
检查本周是否有即将到期的合同续签、员工试用期届满或应收账款逾期。
|
||||
进入「法务参谋」可快速评估风险并生成行动清单。
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => navigate('/legal')}
|
||||
>
|
||||
前往法务参谋 <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 快速入口 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">快速入口</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{QUICK_ENTRIES.map((entry) => (
|
||||
<Card
|
||||
key={entry.to}
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={() => navigate(entry.to)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(entry.to)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-secondary">
|
||||
<entry.icon className="h-5 w-5 text-foreground" aria-hidden />
|
||||
</div>
|
||||
<p className="font-medium">{entry.label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{entry.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 风险等级说明 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity className="h-4 w-4" aria-hidden />
|
||||
风险分级说明
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ level: '绿色', desc: '一般信息查询,可自助处理', cls: 'bg-emerald-50 border-emerald-200' },
|
||||
{ level: '黄色', desc: '事实不完整,需补证', cls: 'bg-amber-50 border-amber-200' },
|
||||
{ level: '橙色', desc: '可能明显损失,建议复核', cls: 'bg-orange-50 border-orange-200' },
|
||||
{ level: '红色', desc: '诉讼/刑事/重大税务,必须升级', cls: 'bg-rose-50 border-rose-200' },
|
||||
].map((r) => (
|
||||
<div key={r.level} className={`rounded-md border p-3 ${r.cls}`}>
|
||||
<p className="text-sm font-semibold">{r.level}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{r.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{health.status === 'error' && (
|
||||
<ErrorState message="无法连接后端服务,请确认 uvicorn 已在 8000 端口运行" onRetry={checkHealth} />
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Lightbulb, Scale, TrendingUp, Wallet, Store, Handshake, Tag, UserPlus, Repeat } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const FRAMEWORK = [
|
||||
{ icon: TrendingUp, title: '预期收益', desc: '各方案的收益估算与时间线' },
|
||||
{ icon: Scale, title: '主要风险', desc: '每个方案的核心风险点' },
|
||||
{ icon: Wallet, title: '现金流影响', desc: '对短期和长期现金流的冲击' },
|
||||
{ icon: Lightbulb, title: '推荐程度', desc: '综合评估后的推荐等级' },
|
||||
]
|
||||
|
||||
/** 决策话题:每个话题对应不同的示例问题 */
|
||||
const TOPICS = [
|
||||
{
|
||||
icon: Store,
|
||||
title: '扩张收缩',
|
||||
desc: '开店、关店、业务线调整',
|
||||
samples: [
|
||||
'现在适合扩张开第二家店吗?',
|
||||
'要不要关闭亏损的业务线?',
|
||||
'新业务线该自营还是加盟?',
|
||||
'应该先扩张还是先巩固现有市场?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Handshake,
|
||||
title: '项目承接',
|
||||
desc: '大单、长期合同、付款条件',
|
||||
samples: [
|
||||
'是否应该接一个付款周期 90 天的大单?',
|
||||
'长期框架合同要不要签?',
|
||||
'预付款比例低的项目能接吗?',
|
||||
'客户要求独家合作,利弊怎么权衡?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Tag,
|
||||
title: '定价策略',
|
||||
desc: '降价、涨价、促销',
|
||||
samples: [
|
||||
'该不该降价 15% 抢竞品的客户?',
|
||||
'产品涨价 10% 会流失多少客户?',
|
||||
'要不要做满减促销活动?',
|
||||
'新产品定价应该走高端还是性价比?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: UserPlus,
|
||||
title: '招人裁员',
|
||||
desc: '关键岗位、团队扩张、减员',
|
||||
samples: [
|
||||
'应该招一个销售总监吗?',
|
||||
'要不要招应届生自己培养?',
|
||||
'团队人效低,该裁谁?',
|
||||
'核心员工被挖,要不要加薪留人?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Wallet,
|
||||
title: '融资合伙',
|
||||
desc: '投资、入股、融资',
|
||||
samples: [
|
||||
'要不要接受天使投资?',
|
||||
'合伙人要退股,怎么谈条件?',
|
||||
'朋友要入股,股权怎么分?',
|
||||
'银行贷款还是股权融资更合适?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Repeat,
|
||||
title: '转型外包',
|
||||
desc: '业务转型、外包、合作',
|
||||
samples: [
|
||||
'传统业务要不要转型线上?',
|
||||
'非核心业务外包还是自己做?',
|
||||
'要不要和同行做联合采购?',
|
||||
'代工还是自建生产线?',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 经营决策助手:情景推演、方案对比、收益/风险/现金流/推荐程度 */
|
||||
export function Decisions() {
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="经营决策助手"
|
||||
description="项目承接、扩张收缩、招人、降价、业务线调整、融资合伙。情景推演,把利弊讲透。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">决策推演框架</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FRAMEWORK.map((f) => (
|
||||
<div key={f.title} className="rounded-md border p-3">
|
||||
<f.icon className="mb-2 h-5 w-5 text-primary" aria-hidden />
|
||||
<p className="text-sm font-medium">{f.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">决策话题</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TOPICS.map((t, i) => (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="decision"
|
||||
samples={TOPICS[selectedTopic].samples}
|
||||
topic={TOPICS[selectedTopic].title}
|
||||
defaultMode="normal"
|
||||
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
/>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
AI 提供情景推演和利弊分析,最终决策由老板做出。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { Brain, HeartHandshake, Zap, Phone } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EmotionMode } from '@/types'
|
||||
|
||||
const MODES: { value: EmotionMode; label: string; desc: string; icon: typeof Brain; samples: string[] }[] = [
|
||||
{
|
||||
value: 'calm',
|
||||
label: '冷静分析',
|
||||
desc: '事实、风险、方案优先,克制客观',
|
||||
icon: Brain,
|
||||
samples: [
|
||||
'合伙人背叛了,我很愤怒,该怎么处理?',
|
||||
'明天的谈判我拖延了一周没准备',
|
||||
'员工集体离职,我该怎么应对?',
|
||||
'竞品抄袭我们,理性分析该怎么反击',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'companion',
|
||||
label: '陪伴疏导',
|
||||
desc: '先承接情绪,再给出下一步,温和不评判',
|
||||
icon: HeartHandshake,
|
||||
samples: [
|
||||
'最近压力很大,睡不着觉',
|
||||
'感觉撑不下去了',
|
||||
'家人不理解我创业的辛苦',
|
||||
'今天被客户骂了,很难受',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'push',
|
||||
label: '强力推动',
|
||||
desc: '把拖延拆成今天可完成的小动作,直接有力',
|
||||
icon: Zap,
|
||||
samples: [
|
||||
'一直拖延不想做年底复盘',
|
||||
'知道该运动但就是不动',
|
||||
'积压了一堆邮件不想处理',
|
||||
'该打的催款电话一直拖着不打',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 情绪价值引擎:三模式切换 + 危机干预入口 */
|
||||
export function EmotionCenter() {
|
||||
const [emotionMode, setEmotionMode] = useState<EmotionMode>('calm')
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="情绪价值引擎"
|
||||
description="先处理情绪,再处理事情。选择适合你当前状态的模式。"
|
||||
>
|
||||
{/* 危机干预提示 */}
|
||||
<Card className="border-rose-200 bg-rose-50">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<Phone className="h-5 w-5 shrink-0 text-rose-600" aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-rose-900">危机干预</p>
|
||||
<p className="text-xs text-rose-700">
|
||||
如果你正在经历强烈痛苦或有自伤念头,请立即拨打心理援助热线 400-161-9995 或前往最近医院急诊。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 情绪模式选择器 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">回复模式</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => setEmotionMode(m.value)}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-md border p-4 text-left transition-colors',
|
||||
emotionMode === m.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<m.icon className="mb-1 h-5 w-5 text-primary" aria-hidden />
|
||||
<span className="text-sm font-medium">{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{m.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="emotion"
|
||||
samples={MODES.find((m) => m.value === emotionMode)?.samples || []}
|
||||
topic={MODES.find((m) => m.value === emotionMode)?.label}
|
||||
defaultMode="ephemeral"
|
||||
emotionMode={emotionMode}
|
||||
placeholder="说说你现在的感受…"
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
/>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Users,
|
||||
Scale,
|
||||
TrendingUp,
|
||||
Heart,
|
||||
Coffee,
|
||||
Star,
|
||||
Check,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
XCircle,
|
||||
} 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 { ExpertAvatar } from '@/components/common/ExpertAvatar'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Expert, ExpertService, Consultation } from '@/types'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'legal', label: '法务', icon: Scale },
|
||||
{ value: 'decision', label: '经营', icon: TrendingUp },
|
||||
{ value: 'emotion', label: '情绪', icon: Heart },
|
||||
{ value: 'life', label: '生活', icon: Coffee },
|
||||
]
|
||||
|
||||
/** 专家库:分类浏览专家,选定专家,查看服务和咨询 */
|
||||
export function Experts() {
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const [services, setServices] = useState<ExpertService[]>([])
|
||||
const [consultations, setConsultations] = useState<Consultation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterCategory, setFilterCategory] = useState<string>('all')
|
||||
const [selectingExpert, setSelectingExpert] = useState<Expert | null>(null)
|
||||
const [cancelService, setCancelService] = useState<ExpertService | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [tab, setTab] = useState<'experts' | 'services' | 'consultations'>('experts')
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [expertRes, serviceRes, consultRes] = await Promise.all([
|
||||
api.listExperts(filterCategory !== 'all' ? filterCategory : undefined),
|
||||
api.myServices(),
|
||||
api.myConsultations(),
|
||||
])
|
||||
setExperts(expertRes.results)
|
||||
setServices(serviceRes.results)
|
||||
setConsultations(consultRes.results)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [filterCategory])
|
||||
|
||||
const handleSelect = async (expert: Expert) => {
|
||||
setSelectingExpert(expert)
|
||||
try {
|
||||
await api.selectExpert(expert.id)
|
||||
toast.success(`已选定${expert.name},等待专家确认`)
|
||||
setSelectingExpert(null)
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '选定失败')
|
||||
setSelectingExpert(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认取消服务关系 */
|
||||
const handleCancelService = async () => {
|
||||
if (!cancelService) return
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.cancelMyService(cancelService.service_id)
|
||||
toast.success('服务关系已取消')
|
||||
setCancelService(null)
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '取消失败')
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const serviceStatusMap = new Map(services.map(s => [s.expert_id, s.status]))
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="专家库"
|
||||
description="分类浏览法务、经营、情绪、生活专家,选定后可一键将 AI 对话话题发给专家咨询。"
|
||||
>
|
||||
{/* Tab 切换 */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button variant={tab === 'experts' ? 'default' : 'outline'} size="sm" onClick={() => setTab('experts')}>
|
||||
<Users className="h-3.5 w-3.5" /> 专家列表
|
||||
</Button>
|
||||
<Button variant={tab === 'services' ? 'default' : 'outline'} size="sm" onClick={() => setTab('services')}>
|
||||
<Check className="h-3.5 w-3.5" /> 我的服务 ({services.length})
|
||||
</Button>
|
||||
<Button variant={tab === 'consultations' ? 'default' : 'outline'} size="sm" onClick={() => setTab('consultations')}>
|
||||
<MessageSquare className="h-3.5 w-3.5" /> 我的咨询 ({consultations.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tab === 'experts' && (
|
||||
<>
|
||||
{/* 分类筛选 */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button variant={filterCategory === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilterCategory('all')}>
|
||||
全部
|
||||
</Button>
|
||||
{CATEGORIES.map((c) => (
|
||||
<Button key={c.value} variant={filterCategory === c.value ? 'default' : 'outline'} size="sm" onClick={() => setFilterCategory(c.value)}>
|
||||
<c.icon className="h-3.5 w-3.5" /> {c.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? <LoadingState /> : error ? (
|
||||
<EmptyState title="加载失败" description={error} icon={<Users className="h-8 w-8" />} />
|
||||
) : experts.length === 0 ? (
|
||||
<EmptyState title="暂无专家" description="该分类下还没有上架的专家" icon={<Users className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{experts.map((expert) => {
|
||||
const catInfo = CATEGORIES.find(c => c.value === expert.category)
|
||||
const serviceStatus = serviceStatusMap.get(expert.id)
|
||||
const stars = (expert.rate / 100).toFixed(1)
|
||||
return (
|
||||
<Card key={expert.id}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<ExpertAvatar name={expert.name} avatarUrl={expert.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="font-medium truncate">{expert.name}</h3>
|
||||
{catInfo && <Badge variant="secondary">{catInfo.label}</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground truncate">{expert.title}</p>
|
||||
<div className="mt-1 flex items-center gap-1 text-xs">
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
<span>{stars}</span>
|
||||
<span className="text-muted-foreground">({expert.rating_count}人评价)</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">{expert.bio}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{expert.expertise.split(',').map((tag, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 border-t pt-3">
|
||||
{serviceStatus === 'active' ? (
|
||||
<Badge className="w-full justify-center"><Check className="h-3 w-3 mr-1" />服务中</Badge>
|
||||
) : serviceStatus === 'pending' ? (
|
||||
<Badge variant="outline" className="w-full justify-center">等待专家确认</Badge>
|
||||
) : (
|
||||
<Button size="sm" className="w-full" onClick={() => handleSelect(expert)} disabled={selectingExpert?.id === expert.id}>
|
||||
{selectingExpert?.id === expert.id && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
|
||||
选定专家
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'services' && (
|
||||
<>
|
||||
{loading ? <LoadingState /> : services.length === 0 ? (
|
||||
<EmptyState title="暂无服务" description="选定专家后,服务关系将显示在这里" icon={<Check className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{services.map((s) => {
|
||||
const catInfo = CATEGORIES.find(c => c.value === s.category)
|
||||
return (
|
||||
<Card key={s.service_id}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{s.expert_name}</h3>
|
||||
{catInfo && <Badge variant="secondary" className="shrink-0">{catInfo.label}</Badge>}
|
||||
<span className="truncate text-sm text-muted-foreground">{s.expert_title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{s.expert_bio}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">选定时间:{s.created_at}</p>
|
||||
</div>
|
||||
<Badge variant={s.status === 'active' ? 'default' : 'outline'} className="shrink-0">
|
||||
{s.status === 'active' ? '服务中' : s.status === 'pending' ? '待确认' : '已结束'}
|
||||
</Badge>
|
||||
</div>
|
||||
{(s.status === 'active' || s.status === 'pending') && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'consultations' && (
|
||||
<>
|
||||
{loading ? <LoadingState /> : consultations.length === 0 ? (
|
||||
<EmptyState title="暂无咨询" description="在与 AI 对话时,可一键将话题发给专家" icon={<MessageSquare className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{consultations.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{c.expert_name}</h3>
|
||||
<span className="truncate text-sm text-muted-foreground">{c.expert_title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium">{c.topic}</p>
|
||||
{c.question && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<Markdown content={c.question} />
|
||||
</div>
|
||||
)}
|
||||
{c.reply && (
|
||||
<div className="mt-2 rounded-md bg-muted/30 p-3 text-sm">
|
||||
<p className="font-medium text-xs text-muted-foreground mb-1">专家回复:</p>
|
||||
<Markdown content={c.reply} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">{c.created_at}</p>
|
||||
</div>
|
||||
<Badge variant={c.status === 'replied' ? 'default' : 'outline'} className="shrink-0">
|
||||
{c.status === 'replied' ? '已回复' : c.status === 'pending' ? '待回复' : '已关闭'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 取消服务确认弹窗 */}
|
||||
<Dialog open={!!cancelService} onOpenChange={(o) => !o && setCancelService(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>取消服务关系</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认取消与「{cancelService?.expert_name}」的服务关系?取消后不可恢复,如需再次服务请重新选定。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelService(null)}>再想想</Button>
|
||||
<Button variant="destructive" onClick={handleCancelService} 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>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
BookOpen,
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
FileText,
|
||||
Scale,
|
||||
FileEdit,
|
||||
MapPin,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Factory,
|
||||
Heart,
|
||||
Coffee,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { KnowledgeDoc } from '@/types'
|
||||
|
||||
const DOC_TYPES = [
|
||||
{ value: 'law', label: '法规', icon: Scale },
|
||||
{ value: 'case', label: '案例', icon: FileText },
|
||||
{ value: 'template', label: '模板', icon: FileEdit },
|
||||
{ value: 'guide', label: '经营指南', icon: TrendingUp },
|
||||
{ value: 'industry', label: '行业知识', icon: Factory },
|
||||
{ value: 'emotion', label: '情绪疏导', icon: Heart },
|
||||
{ value: 'life', label: '生活常识', icon: Coffee },
|
||||
]
|
||||
|
||||
/** 来源英文 → 中文映射 */
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
seed: '系统内置',
|
||||
manual: '手动录入',
|
||||
import: '导入',
|
||||
api: 'API 录入',
|
||||
}
|
||||
|
||||
/** 法规知识库管理:7 类分类,录入、检索、删除 */
|
||||
export function KnowledgeBase() {
|
||||
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<KnowledgeDoc[] | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgeDoc | null>(null)
|
||||
|
||||
// 新增表单
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
doc_type: 'law',
|
||||
region: '',
|
||||
effective_date: '',
|
||||
keywords: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadDocs = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.listKnowledge(filterType !== 'all' ? filterType : undefined)
|
||||
setDocs(res.results)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDocs()
|
||||
}, [filterType])
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await api.searchKnowledge(searchQuery.trim())
|
||||
setSearchResults(res.results)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '检索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.title.trim() || !form.content.trim()) {
|
||||
toast.error('标题和内容不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.addKnowledge({
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
doc_type: form.doc_type,
|
||||
region: form.region.trim() || null,
|
||||
effective_date: form.effective_date.trim() || null,
|
||||
keywords: form.keywords.trim() || null,
|
||||
})
|
||||
toast.success('文档已添加')
|
||||
setAddOpen(false)
|
||||
setForm({ title: '', content: '', doc_type: 'law', region: '', effective_date: '', keywords: '' })
|
||||
loadDocs()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '添加失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return
|
||||
try {
|
||||
await api.deleteKnowledge(deleteTarget.id)
|
||||
toast.success('文档已删除')
|
||||
setDeleteTarget(null)
|
||||
loadDocs()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const displayDocs = searchResults ?? docs
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="知识库"
|
||||
description="7 类知识库:法规、案例、模板、经营指南、行业知识、情绪疏导、生活常识。提问时按场景自动注入相关知识。"
|
||||
actions={
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-3.5 w-3.5" /> 新增文档
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-w-[95vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增知识文档</DialogTitle>
|
||||
<DialogDescription>录入后将进入 RAG 检索库,提问时按场景自动引用相关知识。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="kb-title">标题</Label>
|
||||
<Input id="kb-title" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:劳动合同法-试用期解除" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-content">内容</Label>
|
||||
<Textarea id="kb-content" value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} placeholder="法规条文、案例摘要或模板正文" className="min-h-[120px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="kb-type">类型</Label>
|
||||
<Select value={form.doc_type} onValueChange={(v) => setForm({ ...form, doc_type: v })}>
|
||||
<SelectTrigger id="kb-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOC_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-region">地区</Label>
|
||||
<Input id="kb-region" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="如:全国 / 上海" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="kb-date">生效日期</Label>
|
||||
<Input id="kb-date" type="date" value={form.effective_date} onChange={(e) => setForm({ ...form, effective_date: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-keywords">关键词</Label>
|
||||
<Input id="kb-keywords" value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="逗号分隔,如:试用期,解除" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAdd} disabled={saving}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />} 保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
>
|
||||
{/* 检索栏 */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="检索法规、案例、经营指南、行业知识…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={searching}>
|
||||
{searching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
|
||||
检索
|
||||
</Button>
|
||||
{searchResults && (
|
||||
<Button variant="outline" onClick={() => { setSearchResults(null); setSearchQuery('') }}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 类型筛选 */}
|
||||
{!searchResults && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filterType === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType('all')}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{DOC_TYPES.map((t) => (
|
||||
<Button
|
||||
key={t.value}
|
||||
variant={filterType === t.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType(t.value)}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" /> {t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 文档列表 */}
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadDocs} />
|
||||
) : displayDocs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={searchResults ? '未找到匹配文档' : '知识库为空'}
|
||||
description={searchResults ? '尝试更换关键词' : '点击右上角新增文档,录入法规、案例、经营指南等知识'}
|
||||
icon={<BookOpen className="h-8 w-8" aria-hidden />}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{searchResults && (
|
||||
<p className="text-sm text-muted-foreground">找到 {searchResults.length} 条匹配结果</p>
|
||||
)}
|
||||
{displayDocs.map((doc) => {
|
||||
const typeInfo = DOC_TYPES.find((t) => t.value === doc.doc_type)
|
||||
return (
|
||||
<Card key={doc.id} className={cn(searchResults && doc.score && 'border-l-4 border-l-primary')}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{typeInfo && <typeInfo.icon className="h-4 w-4 text-muted-foreground" aria-hidden />}
|
||||
<h3 className="font-medium">{doc.title}</h3>
|
||||
{doc.doc_type && (
|
||||
<Badge variant="secondary">{typeInfo?.label || doc.doc_type}</Badge>
|
||||
)}
|
||||
{searchResults && doc.score && (
|
||||
<Badge variant="outline">相关度 {doc.score}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" /> 来源:{SOURCE_LABELS[doc.source] || doc.source}
|
||||
</span>
|
||||
{doc.region && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" /> {doc.region}
|
||||
</span>
|
||||
)}
|
||||
{doc.effective_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" /> {doc.effective_date}
|
||||
</span>
|
||||
)}
|
||||
{doc.keywords && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" /> {doc.keywords}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!searchResults && doc.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-rose-600"
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除?</DialogTitle>
|
||||
<DialogDescription>将永久删除「{deleteTarget?.title}」,此操作不可恢复。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Briefcase, FileText, Coins, Users, Receipt, Gavel } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 法务话题:每个话题对应不同的示例问题 */
|
||||
const TOPICS = [
|
||||
{
|
||||
icon: Briefcase,
|
||||
title: '劳动用工',
|
||||
desc: '工伤、解雇、工资、社保',
|
||||
samples: [
|
||||
'员工工伤后能否开除?',
|
||||
'员工旷工三天怎么处理?',
|
||||
'试用期不合格怎么辞退才合法?',
|
||||
'加班费计算基数怎么确定?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: '合同审查',
|
||||
desc: '违约金、付款条款、风险点',
|
||||
samples: [
|
||||
'这份合同付款条款有什么风险?',
|
||||
'合同违约金怎么约定才合理?',
|
||||
'对方提供的格式合同有哪些坑?',
|
||||
'口头协议有法律效力吗?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Coins,
|
||||
title: '债权债务',
|
||||
desc: '催款、欠款、债务纠纷',
|
||||
samples: [
|
||||
'客户拖欠货款 3 个月如何催收?',
|
||||
'欠款人跑了,钱还能要回来吗?',
|
||||
'供应商要求先款后货,有风险吗?',
|
||||
'债务超过诉讼时效怎么办?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: '股东纠纷',
|
||||
desc: '股权、分红、退股',
|
||||
samples: [
|
||||
'合伙人要退股,怎么谈条件?',
|
||||
'小股东查账被拒绝怎么办?',
|
||||
'股东会决议不执行怎么处理?',
|
||||
'隐名股东权益如何保护?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Receipt,
|
||||
title: '税务风险',
|
||||
desc: '稽查、发票、合规',
|
||||
samples: [
|
||||
'收到税务稽查通知怎么办?',
|
||||
'公司缺进项发票怎么解决?',
|
||||
'个税汇算清缴有哪些风险点?',
|
||||
'关联交易定价被质疑怎么办?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Gavel,
|
||||
title: '诉讼仲裁',
|
||||
desc: '起诉、应诉、执行',
|
||||
samples: [
|
||||
'官司打赢了对方不执行怎么办?',
|
||||
'收到法院传票要不要请律师?',
|
||||
'仲裁和诉讼哪个更适合?',
|
||||
'诉讼时效快到了还能起诉吗?',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 法务参谋:劳动用工、合同审查、债权债务、股东纠纷、税务风险 */
|
||||
export function LegalAdvisor() {
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="法务参谋"
|
||||
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险。AI 输出不替代执业律师正式意见。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">法务话题</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TOPICS.map((t, i) => (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="legal"
|
||||
samples={TOPICS[selectedTopic].samples}
|
||||
topic={TOPICS[selectedTopic].title}
|
||||
defaultMode="confidential"
|
||||
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
|
||||
showRiskCard
|
||||
enableUpload
|
||||
/>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。
|
||||
重大风险事项请寻求执业专业人士复核。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Heart, Users, Plane, Moon, GraduationCap, Stethoscope } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 话题定义:每个话题对应不同的示例问题 */
|
||||
const TOPICS = [
|
||||
{
|
||||
icon: Users,
|
||||
title: '家庭沟通',
|
||||
desc: '伴侣、子女、父母关系',
|
||||
samples: [
|
||||
'怎么和青春期的孩子沟通不想他接班的事?',
|
||||
'和伴侣在花钱观念上有分歧,怎么谈?',
|
||||
'父母总是干涉经营决策,怎么温和地划清边界?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
title: '子女教育',
|
||||
desc: '升学、留学、职业方向',
|
||||
samples: [
|
||||
'孩子中考成绩一般,选普高还是国际学校?',
|
||||
'要不要送孩子出国留学?什么阶段出去最合适?',
|
||||
'孩子想学艺术,但担心就业,该怎么引导?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: '父母养老',
|
||||
desc: '照护方案、医疗安排',
|
||||
samples: [
|
||||
'父母年纪大了,居家养老还是去养老机构?',
|
||||
'父母需要长期服药,怎么管理用药和复诊?',
|
||||
'怎么和兄弟姐妹分担父母的养老责任?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Moon,
|
||||
title: '睡眠与压力',
|
||||
desc: '身心状态管理',
|
||||
samples: [
|
||||
'最近睡眠很差,有什么改善建议?',
|
||||
'经营压力大,经常焦虑,怎么自我调节?',
|
||||
'长期高强度工作,怎么避免职业倦怠?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Plane,
|
||||
title: '出差日程',
|
||||
desc: '行程、商务礼仪',
|
||||
samples: [
|
||||
'下周出差三天,怎么安排最高效?',
|
||||
'第一次见重要客户,商务礼仪要注意什么?',
|
||||
'出差期间公司有事,怎么远程处理不耽误?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Stethoscope,
|
||||
title: '健康习惯',
|
||||
desc: '体检、运动、饮食',
|
||||
samples: [
|
||||
'体检报告有几项异常,需要进一步检查吗?',
|
||||
'久坐办公,每天多少运动量才够?',
|
||||
'应酬多,怎么调整饮食减少对身体的负担?',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 老板生活助理:降低非经营决策负担 */
|
||||
export function LifeAssistant() {
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="老板生活助理"
|
||||
description="家庭、健康、出差、重要事项。帮你处理经营之外的事,把精力留给关键决策。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">支持话题</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TOPICS.map((t, i) => (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="life"
|
||||
samples={TOPICS[selectedTopic].samples}
|
||||
topic={TOPICS[selectedTopic].title}
|
||||
defaultMode="confidential"
|
||||
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { setAuth, type Role } from '@/lib/auth'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, ShieldCheck, UserCog, Briefcase, Shield, User } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 角色配置 */
|
||||
const ROLES: Array<{
|
||||
value: Role
|
||||
label: string
|
||||
icon: typeof User
|
||||
desc: string
|
||||
home: string
|
||||
testAccount?: { phone: string; password: string; name: string }
|
||||
}> = [
|
||||
{
|
||||
value: 'user',
|
||||
label: '用户',
|
||||
icon: User,
|
||||
desc: '老板专属经营助手',
|
||||
home: '/',
|
||||
testAccount: { phone: '13800138000', password: '123456', name: '测试老板' },
|
||||
},
|
||||
{
|
||||
value: 'expert',
|
||||
label: '专家',
|
||||
icon: Briefcase,
|
||||
desc: '专家工作台,接单回复',
|
||||
home: '/workbench',
|
||||
testAccount: { phone: '13910000001', password: 'expert123', name: '李华律师' },
|
||||
},
|
||||
{
|
||||
value: 'admin',
|
||||
label: '管理员',
|
||||
icon: Shield,
|
||||
desc: '平台管理后台',
|
||||
home: '/admin',
|
||||
testAccount: { phone: '13800000000', password: 'admin123', name: '平台管理员' },
|
||||
},
|
||||
]
|
||||
|
||||
/** 登录/注册页:手机号+密码,支持用户/专家/管理员三种角色 */
|
||||
export function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [role, setRole] = useState<Role>('user')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const currentRole = ROLES.find((r) => r.value === role)!
|
||||
|
||||
/** 填充测试账号 */
|
||||
const fillTestAccount = () => {
|
||||
if (currentRole.testAccount) {
|
||||
setPhone(currentRole.testAccount.phone)
|
||||
setPassword(currentRole.testAccount.password)
|
||||
setName(currentRole.testAccount.name)
|
||||
setMode('login')
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换角色时清空表单 */
|
||||
const switchRole = (r: Role) => {
|
||||
setRole(r)
|
||||
setPhone('')
|
||||
setPassword('')
|
||||
setName('')
|
||||
setMode('login')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 前端校验
|
||||
if (!phone.trim()) {
|
||||
toast.error('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!password.trim()) {
|
||||
toast.error('请输入密码')
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
toast.error('手机号格式不正确,应为 11 位')
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error('密码至少 6 位')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
// 专家和管理员只支持登录,不支持注册
|
||||
if (role !== 'user' && mode === 'register') {
|
||||
toast.error('专家和管理员账号由平台统一创建,无需注册')
|
||||
setMode('login')
|
||||
return
|
||||
}
|
||||
const result = mode === 'login'
|
||||
? await api.login(phone, password)
|
||||
: await api.register(phone, password, name)
|
||||
// 登录返回 role,注册默认为 user
|
||||
const resultRole: Role = mode === 'login'
|
||||
? ((result as { role?: string }).role as Role) || 'user'
|
||||
: 'user'
|
||||
setAuth(result.token, result.phone, result.name, resultRole)
|
||||
toast.success(mode === 'login' ? '登录成功' : '注册成功')
|
||||
// 按角色跳转
|
||||
const targetRole = ROLES.find((r) => r.value === resultRole) || currentRole
|
||||
navigate(targetRole.home)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '操作失败'
|
||||
toast.error(msg, { duration: 4000 })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{mode === 'login' ? '登录 BossAi' : '注册 BossAi'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentRole.desc}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 角色 Tab */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg border p-2.5 text-sm transition',
|
||||
role === r.value
|
||||
? 'border-primary bg-primary/5 text-primary ring-1 ring-primary/20'
|
||||
: 'border-border text-muted-foreground hover:bg-muted/40',
|
||||
)}
|
||||
onClick={() => switchRole(r.value)}
|
||||
>
|
||||
<r.icon className="h-4 w-4" />
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
{mode === 'register' && role === 'user' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">昵称(可选)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="怎么称呼你?"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === 'login' ? '登录' : '注册'}
|
||||
</Button>
|
||||
|
||||
{/* 注册入口仅用户显示 */}
|
||||
{role === 'user' && (
|
||||
<div className="text-center text-sm">
|
||||
{mode === 'login' ? (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
没有账号?去注册
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
已有账号?去登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试账号快捷填充 */}
|
||||
{currentRole.testAccount && (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{currentRole.label}测试账号 {currentRole.testAccount.phone}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fillTestAccount} disabled={loading}>
|
||||
<UserCog className="mr-2 h-3.5 w-3.5" />
|
||||
一键填充
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Construction } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
|
||||
/** 占位页:尚未实现的模块 */
|
||||
export function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<PageLayout title={title} description="该模块将在后续迭代实现">
|
||||
<EmptyState
|
||||
title="建设中"
|
||||
description="此功能尚未开放,后续迭代上线。"
|
||||
icon={<Construction className="h-8 w-8" aria-hidden />}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Shield,
|
||||
Cloud,
|
||||
Database,
|
||||
Trash2,
|
||||
Bell,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
Loader2,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Settings } from '@/types'
|
||||
|
||||
/** 设置项配置 */
|
||||
const SETTING_GROUPS = [
|
||||
{
|
||||
title: '隐私与会话',
|
||||
icon: Shield,
|
||||
desc: '控制数据留存和销毁策略',
|
||||
items: [
|
||||
{ key: 'show_inference_location', label: '显示推理位置', desc: '在对话中标注当前处理位置(本地/边缘/云端),保持透明', icon: Eye, danger: false },
|
||||
{ key: 'ephemeral_disable_copy', label: '阅后即焚默认禁用复制', desc: '阅后即焚模式下禁止复制、导出、转发和长期记忆', icon: EyeOff, danger: false },
|
||||
{ key: 'auto_destroy_on_exit', label: '退出会话自动销毁', desc: '离开页面或关闭应用时自动销毁阅后即焚会话', icon: Trash2, danger: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据与记忆',
|
||||
icon: Database,
|
||||
desc: '控制 AI 记忆范围和数据用途',
|
||||
items: [
|
||||
{ key: 'long_term_memory', label: '长期记忆', desc: '记住你的压力来源、性格和决策风格,提供个性化建议。可随时关闭', icon: Database, danger: false },
|
||||
{ key: 'knowledge_base_write', label: '企业知识库写入', desc: '允许将脱敏内容写入企业知识库,供团队复用', icon: Database, danger: false },
|
||||
{ key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, BossAi 不采集训练数据', icon: Info, danger: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '云端授权',
|
||||
icon: Cloud,
|
||||
desc: '本地能力不足时,控制是否调用云端模型',
|
||||
items: [
|
||||
{ key: 'cloud_call_allowed', label: '允许云端调用', desc: '本地模型能力不足时,经授权后调用云端模型。关闭后仅使用本地/边缘推理', icon: Cloud, danger: false },
|
||||
{ key: 'pii_redact_on_transfer', label: 'PII 脱敏后传输', desc: '手机号、身份证、银行卡等敏感信息在传输前自动脱敏', icon: Shield, danger: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '通知与自动升级',
|
||||
icon: Bell,
|
||||
desc: '每日提醒、风险预警和自动升级规则',
|
||||
items: [
|
||||
{ key: 'daily_reminder', label: '每日经营提醒', desc: '每天早上推送今天最该做的一件事', icon: Bell, danger: false },
|
||||
{ key: 'risk_alert', label: '风险预警', desc: '检测到合同到期、应收逾期、员工试用期届满等风险时主动提醒', icon: Shield, danger: false },
|
||||
{ key: 'auto_escalate_red', label: '红色风险自动升级', desc: '检测到红色风险时自动创建复核工单,无需手动操作', icon: Shield, danger: false },
|
||||
{ key: 'auto_escalate_orange', label: '橙色风险自动升级', desc: '检测到橙色风险时自动创建复核工单(默认手动)', icon: Shield, danger: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 设置:从后端加载,修改后持久化到 PostgreSQL */
|
||||
export function Settings() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [pendingChanges, setPendingChanges] = useState<Settings>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.getSettings()
|
||||
setSettings(res.settings)
|
||||
setPendingChanges({})
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载设置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const toggle = (key: string) => {
|
||||
const current = settings?.[key] ?? 'false'
|
||||
const newValue = current === 'true' ? 'false' : 'true'
|
||||
setPendingChanges((prev) => ({ ...prev, [key]: newValue }))
|
||||
setSettings((prev) => prev ? { ...prev, [key]: newValue } : prev)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await api.updateSettings(pendingChanges)
|
||||
setSettings(res.settings)
|
||||
setPendingChanges({})
|
||||
setDirty(false)
|
||||
toast.success('设置已保存')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <PageLayout title="设置"><LoadingState /></PageLayout>
|
||||
if (error) return <PageLayout title="设置"><ErrorState message={error} onRetry={load} /></PageLayout>
|
||||
if (!settings) return null
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="设置"
|
||||
description="隐私偏好、数据留存、云端授权和通知设置。你对自己的数据拥有完全控制权。"
|
||||
actions={
|
||||
dirty ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load}>放弃</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{SETTING_GROUPS.map((group) => (
|
||||
<Card key={group.title}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<group.icon className="h-4 w-4" aria-hidden /> {group.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{group.desc}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{group.items.map((item) => {
|
||||
const on = settings[item.key] === 'true'
|
||||
const pending = pendingChanges[item.key]
|
||||
const isPending = pending !== undefined
|
||||
return (
|
||||
<div key={item.key} className="flex items-start gap-3 py-3">
|
||||
<item.icon className={cn('mt-0.5 h-5 w-5 shrink-0', item.danger ? 'text-rose-600' : 'text-primary')} aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{item.label}
|
||||
{isPending && <span className="ml-2 text-xs text-amber-600">未保存</span>}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(item.key)}
|
||||
className={cn(
|
||||
'relative h-6 w-11 shrink-0 rounded-full transition-colors',
|
||||
on ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
on ? 'translate-x-5' : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 危险操作 */}
|
||||
<Card className="border-rose-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base text-rose-700">
|
||||
<Trash2 className="h-4 w-4" aria-hidden /> 危险操作
|
||||
</CardTitle>
|
||||
<CardDescription>以下操作不可恢复,请谨慎执行</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">清除全部会话历史</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">销毁所有普通和保密模式的历史会话</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-rose-300 text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">导出并删除全部数据</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">导出你的全部数据后永久删除</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-rose-300 text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
|
||||
>
|
||||
导出并删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
设置已持久化到 PostgreSQL 数据库,跨会话保留。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Shield, Users, MessageSquare, Activity,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, CartesianGrid, LineChart, Line, Legend,
|
||||
} from 'recharts'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/lib/api'
|
||||
import { SCENE_LABELS, MODE_LABELS, RISK_LABELS, PIE_COLORS } from './constants'
|
||||
|
||||
type AiUsage = Awaited<ReturnType<typeof api.adminAiUsage>>
|
||||
|
||||
/** 管理后台 - AI 使用情况:提问统计、场景/模式/风险/推理位置分布、趋势、用户排行(不含具体内容) */
|
||||
export function AdminAiUsage() {
|
||||
const [data, setData] = useState<AiUsage | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminAiUsage()
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PageLayout title="AI 使用情况" description="用户 AI 使用统计(仅统计,不含具体对话内容)">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="AI 使用情况" description="用户 AI 使用统计(仅统计,不含具体对话内容)">
|
||||
<div className="space-y-6">
|
||||
{/* 概览数字 */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<MessageSquare className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.total_questions}</p>
|
||||
<p className="text-sm text-muted-foreground">总提问数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Users className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.active_users}</p>
|
||||
<p className="text-sm text-muted-foreground">活跃用户数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Activity className="mx-auto h-6 w-6 text-emerald-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.daily_trend.reduce((s, d) => s + d.count, 0)}</p>
|
||||
<p className="text-sm text-muted-foreground">近 7 天提问</p>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
{data.daily_trend.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">近 7 天提问趋势</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data.daily_trend.map(d => ({ date: d.date.slice(5), count: d.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="#3b82f6" strokeWidth={2} name="提问数" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* 场景分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按场景分布</p>
|
||||
{data.by_scene.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data.by_scene.map(s => ({ name: SCENE_LABELS[s.scene] || s.scene, value: s.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label>
|
||||
{data.by_scene.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<MessageSquare className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 隐私模式分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按隐私模式分布</p>
|
||||
{data.by_mode.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data.by_mode.map(m => ({ name: MODE_LABELS[m.mode] || m.mode, value: m.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label>
|
||||
{data.by_mode.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Shield className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 风险等级分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按风险等级分布</p>
|
||||
{data.by_risk.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data.by_risk.map(r => ({ name: RISK_LABELS[r.level] || r.level, count: r.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#f59e0b" name="回复数" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Shield className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 推理位置分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按推理位置分布</p>
|
||||
{data.by_inference.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data.by_inference.map(r => ({ name: r.location === 'cloud' ? '云端' : '本地', count: r.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#10b981" name="回复数" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Activity className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 用户使用排行 */}
|
||||
{data.by_user.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">用户使用情况(脱敏,Top 50)</p>
|
||||
<div className="space-y-2">
|
||||
{data.by_user.map((u, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{u.phone_masked}</span>
|
||||
{u.top_scene && (
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{SCENE_LABELS[u.top_scene] || u.top_scene}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>提问 {u.questions}</span>
|
||||
<span>回复 {u.replies}</span>
|
||||
<span>{u.last_active ? u.last_active.slice(0, 10) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Shield, Activity, Briefcase } from 'lucide-react'
|
||||
import {
|
||||
PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, CartesianGrid, LineChart, Line, Legend,
|
||||
} from 'recharts'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/lib/api'
|
||||
import { CATEGORY_LABELS, STATUS_LABELS, PIE_COLORS } from './constants'
|
||||
|
||||
type ExpertActivity = Awaited<ReturnType<typeof api.adminExpertActivity>>
|
||||
|
||||
/** 管理后台 - 专家沟通情况:咨询统计、状态/分类分布、专家统计、趋势、用户排行(不含具体内容) */
|
||||
export function AdminExpertActivity() {
|
||||
const [data, setData] = useState<ExpertActivity | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminExpertActivity()
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PageLayout title="专家沟通情况" description="用户与专家的咨询统计(仅统计,不含具体咨询内容)">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="专家沟通情况" description="用户与专家的咨询统计(仅统计,不含具体咨询内容)">
|
||||
<div className="space-y-6">
|
||||
{/* 概览数字 */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Activity className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.total}</p>
|
||||
<p className="text-sm text-muted-foreground">总咨询数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-emerald-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.replied}</p>
|
||||
<p className="text-sm text-muted-foreground">已回复</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-amber-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.pending}</p>
|
||||
<p className="text-sm text-muted-foreground">待回复</p>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
{data.daily_trend.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">近 7 天咨询趋势</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data.daily_trend.map(d => ({ date: d.date.slice(5), count: d.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="#10b981" strokeWidth={2} name="咨询数" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* 状态分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按状态分布</p>
|
||||
{data.by_status.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data.by_status.map(s => ({ name: STATUS_LABELS[s.status] || s.status, value: s.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label>
|
||||
{data.by_status.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Activity className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 分类分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按分类分布</p>
|
||||
{data.by_category.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data.by_category.map(c => ({ name: CATEGORY_LABELS[c.category] || c.category, count: c.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#3b82f6" name="咨询数" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Briefcase className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 专家咨询统计 */}
|
||||
{data.by_expert.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">专家咨询统计</p>
|
||||
<div className="space-y-2">
|
||||
{data.by_expert.filter(e => e.total > 0).map((e, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{e.name}</span>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">{CATEGORY_LABELS[e.category] || e.category}</Badge>
|
||||
{e.rating_count > 0 && (
|
||||
<span className="text-xs text-amber-500">★ {e.avg_rating}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>总 {e.total}</span>
|
||||
<span className="text-emerald-500">已回 {e.replied}</span>
|
||||
<span className="text-amber-500">待回 {e.pending}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.by_expert.every(e => e.total === 0) && (
|
||||
<EmptyState title="暂无咨询记录" icon={<Activity className="h-8 w-8" />} />
|
||||
)}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{/* 用户咨询排行 */}
|
||||
{data.by_user.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">用户咨询情况(脱敏,Top 50)</p>
|
||||
<div className="space-y-2">
|
||||
{data.by_user.map((u, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{u.phone_masked}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>咨询 {u.consultations}</span>
|
||||
<span>{u.last_active ? u.last_active.slice(0, 10) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Briefcase, Loader2, Pencil, Power, PowerOff, Star, Trash2, Upload } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ExpertAvatar } from '@/components/common/ExpertAvatar'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Expert } from '@/types'
|
||||
import { CATEGORY_LABELS } from './constants'
|
||||
|
||||
/** 管理后台 - 专家管理:专家上下架、编辑信息、上传头像 */
|
||||
export function AdminExperts() {
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [toggling, setToggling] = useState<number | null>(null)
|
||||
const [editing, setEditing] = useState<Expert | null>(null)
|
||||
|
||||
const loadExperts = async () => {
|
||||
try {
|
||||
const res = await api.adminListExperts()
|
||||
setExperts(res.results)
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadExperts() }, [])
|
||||
|
||||
const handleToggle = async (expert: Expert) => {
|
||||
setToggling(expert.id)
|
||||
try {
|
||||
const newStatus = expert.status === 'active' ? 'inactive' : 'active'
|
||||
await api.adminUpdateExpertStatus(expert.id, newStatus)
|
||||
toast.success(newStatus === 'active' ? '已上架' : '已下架')
|
||||
loadExperts()
|
||||
} catch {
|
||||
toast.error('操作失败')
|
||||
} finally {
|
||||
setToggling(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title="专家管理" description="专家上下架管理">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="专家管理" description="专家上下架、编辑信息、上传头像">
|
||||
{experts.length === 0 ? (
|
||||
<EmptyState title="暂无专家" icon={<Briefcase className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{experts.map((ex) => {
|
||||
const stars = (ex.rate / 100).toFixed(1)
|
||||
return (
|
||||
<Card key={ex.id}>
|
||||
<CardContent className="p-5 pt-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="font-medium truncate">{ex.name}</h3>
|
||||
{ex.status === 'active'
|
||||
? <Badge>上架中</Badge>
|
||||
: <Badge variant="outline">已下架</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground truncate">{ex.title}</p>
|
||||
<div className="mt-1 flex items-center gap-1 text-xs">
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
<span>{stars}</span>
|
||||
<span className="text-muted-foreground">({ex.rating_count}人评价)</span>
|
||||
</div>
|
||||
{/* 简介 */}
|
||||
{ex.bio && (
|
||||
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">{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">{CATEGORY_LABELS[ex.category] || ex.category}</Badge>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(ex)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ex.status === 'active' ? 'destructive' : 'default'}
|
||||
onClick={() => handleToggle(ex)}
|
||||
disabled={toggling === ex.id}
|
||||
>
|
||||
{toggling === ex.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
|
||||
ex.status === 'active' ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
|
||||
{ex.status === 'active' ? '下架' : '上架'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editing && (
|
||||
<EditExpertDialog
|
||||
expert={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSuccess={() => { loadExperts(); setEditing(null) }}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑专家信息弹窗:含头像上传 + 表单字段 */
|
||||
function EditExpertDialog({
|
||||
expert,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
expert: Expert
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(expert.name)
|
||||
const [category, setCategory] = useState(expert.category)
|
||||
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)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await api.adminUploadExpertAvatar(expert.id, file)
|
||||
setAvatarUrl(res.avatar_url)
|
||||
toast.success('头像上传成功')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
// 清空 input 允许重复选同一文件
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除头像:清空 avatarUrl,保存时传空字符串后端清除为 NULL */
|
||||
const handleRemoveAvatar = async () => {
|
||||
setAvatarUrl('')
|
||||
toast.success('头像已删除,保存后生效')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('姓名不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.adminUpdateExpert(expert.id, {
|
||||
name: name.trim(),
|
||||
category,
|
||||
title: title.trim(),
|
||||
bio: bio.trim(),
|
||||
expertise: expertise.trim(),
|
||||
avatar_url: avatarUrl,
|
||||
})
|
||||
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">
|
||||
{/* 头像上传区 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ExpertAvatar name={name || expert.name} avatarUrl={avatarUrl} size="2xl" shape="rect" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">专家头像</p>
|
||||
<p className="text-xs text-muted-foreground">支持 JPG / PNG / WebP,最大 5MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
{uploading ? '上传中…' : avatarUrl ? '更换图片' : '选择图片'}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-rose-600 hover:bg-rose-50"
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 姓名 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-name">姓名 *</Label>
|
||||
<Input id="edit-name" value={name} onChange={(e) => setName(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="edit-title">职称 / 头衔</Label>
|
||||
<Input id="edit-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:高级律师" />
|
||||
</div>
|
||||
|
||||
{/* 简介 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-bio">简介</Label>
|
||||
<Textarea id="edit-bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="专家背景介绍" className="min-h-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* 专长 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-expertise">专长(逗号分隔)</Label>
|
||||
<Input id="edit-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" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Shield, Users, Briefcase, BarChart3 } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 管理后台 - 平台统计:注册用户、专家、服务关系、咨询概览 */
|
||||
export function AdminStats() {
|
||||
const [stats, setStats] = useState<Record<string, number> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminStats()
|
||||
.then(setStats)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title="平台统计" description="用户、专家、服务关系和咨询概览">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="平台统计" description="用户、专家、服务关系和咨询概览">
|
||||
{stats && (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Users className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.users}</p>
|
||||
<p className="text-sm text-muted-foreground">注册用户</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Briefcase className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.experts_active}/{stats.experts_total}</p>
|
||||
<p className="text-sm text-muted-foreground">上架/总专家</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<BarChart3 className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.services_active}</p>
|
||||
<p className="text-sm text-muted-foreground">服务中关系</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.consultations_total}</p>
|
||||
<p className="text-sm text-muted-foreground">总咨询数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-amber-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.consultations_pending}</p>
|
||||
<p className="text-sm text-muted-foreground">待回复咨询</p>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Users } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 管理后台 - 用户管理:列出所有注册用户 */
|
||||
export function AdminUsers() {
|
||||
const [users, setUsers] = useState<Array<{ id: number; phone: string; name: string; created_at: string }>>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminListUsers()
|
||||
.then(res => setUsers(res.results))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title="用户管理" description="平台注册用户列表">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="用户管理" description="平台注册用户列表">
|
||||
{users.length === 0 ? (
|
||||
<EmptyState title="暂无用户" icon={<Users className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id}><CardContent className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{u.name || '未设置昵称'}</p>
|
||||
<p className="text-xs text-muted-foreground">{u.phone}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">注册:{u.created_at}</p>
|
||||
</CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 管理后台共享常量:标签映射、图表配色 */
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
legal: '法务', decision: '经营', emotion: '情绪', life: '生活',
|
||||
}
|
||||
|
||||
export const SCENE_LABELS: Record<string, string> = {
|
||||
legal: '法务参谋', decision: '经营决策', life: '生活助理', emotion: '情绪中心', unknown: '未知',
|
||||
}
|
||||
|
||||
export const MODE_LABELS: Record<string, string> = {
|
||||
normal: '普通模式', confidential: '保密模式',
|
||||
}
|
||||
|
||||
export const RISK_LABELS: Record<string, string> = {
|
||||
green: '低风险', amber: '需补证', orange: '建议复核', red: '高风险', unknown: '未知',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待回复', replied: '已回复', closed: '已关闭',
|
||||
}
|
||||
|
||||
/** 饼图配色 */
|
||||
export const PIE_COLORS = ['#3b82f6', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#6b7280']
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 管理后台页面集合 barrel 导出 */
|
||||
export { AdminStats } from './AdminStats'
|
||||
export { AdminAiUsage } from './AdminAiUsage'
|
||||
export { AdminExpertActivity } from './AdminExpertActivity'
|
||||
export { AdminUsers } from './AdminUsers'
|
||||
export { AdminExperts } from './AdminExperts'
|
||||
@@ -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