feat: BossAi 老板AI助手初始版本

包含功能:
- 法务参谋/经营决策/生活助理/情绪中心四大AI对话模块
- 专家咨询系统(用户选定→专家确认→咨询→回复→评价)
- 角色登录(用户/专家/管理员)+ ProtectedRoute 权限控制
- 管理后台(用户管理/专家管理/AI使用统计/专家活跃度)
- 专家工作台(待确认服务/待回复咨询/已回复咨询/服务中用户)
- 服务关系取消(用户和专家均可取消,需自己确认)
- 全页面移动端响应式适配
- 知识库RAG检索
- 对话历史(普通/保密模式)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-29 23:57:26 +08:00
co-authored by Devin
commit 5c21e8fcd0
101 changed files with 25273 additions and 0 deletions
+447
View File
@@ -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>
)
}
+161
View File
@@ -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>
)
}
+154
View File
@@ -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>
)
}
+113
View File
@@ -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>
)
}
+295
View File
@@ -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>
)
}
+364
View File
@@ -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>
)
}
+130
View File
@@ -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>
)
}
+120
View File
@@ -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>
)
}
+234
View File
@@ -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>
)
}
+16
View File
@@ -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>
)
}
+233
View File
@@ -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>
)
}
+172
View File
@@ -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>
)
}
+161
View File
@@ -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>
)
}
+311
View File
@@ -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>
)
}
+61
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+24
View File
@@ -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']
+6
View File
@@ -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>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Briefcase, Loader2, XCircle } from 'lucide-react'
import { EmptyState } from '@/components/common/EmptyState'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import type { Consultation, ExpertService } from '@/types'
import { api } from '@/lib/api'
import { useWorkbenchData, WorkbenchShell, maskPhone } from './shared'
/** 专家工作台 - 服务中用户:已确认服务关系的用户列表,可取消服务 */
export function WorkbenchUsers() {
const { expert, services, consultations, loading, error, loadData } = useWorkbenchData()
const [cancelService, setCancelService] = useState<ExpertService | null>(null)
const [cancelling, setCancelling] = useState(false)
const activeServices = services.filter(s => s.status === 'active')
/** 按服务用户分组咨询 */
const consultationsByUser = useMemo(() => {
const map = new Map<string, Consultation[]>()
for (const c of consultations) {
const phone = c.user_phone || 'unknown'
if (!map.has(phone)) {
map.set(phone, [])
}
map.get(phone)!.push(c)
}
return map
}, [consultations])
/** 确认取消服务关系 */
const handleCancel = async () => {
if (!cancelService) return
setCancelling(true)
try {
await api.cancelExpertService(cancelService.service_id)
toast.success('服务关系已取消')
setCancelService(null)
loadData()
} catch (e) {
toast.error(e instanceof Error ? e.message : '取消失败')
} finally {
setCancelling(false)
}
}
return (
<WorkbenchShell expert={expert} loading={loading} error={error} title="服务中用户" description="已确认服务关系的用户">
{activeServices.length === 0 ? (
<EmptyState title="暂无服务中用户" description="确认接单的用户会显示在这里" icon={<Briefcase className="h-8 w-8" />} />
) : (
<div className="space-y-2">
{activeServices.map((s) => {
const userConsultations = consultationsByUser.get(s.user_phone || '') || []
return (
<Card key={s.service_id}>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium">用户:{maskPhone(s.user_phone || '')}</p>
<p className="text-xs text-muted-foreground">
签约时间:{s.signed_at}
{userConsultations.length > 0 && ` · ${userConsultations.length} 条咨询`}
</p>
</div>
<Badge className="shrink-0">服务中</Badge>
</div>
<div className="mt-3 border-t pt-3">
<Button
size="sm"
variant="outline"
className="text-rose-600 hover:bg-rose-50"
onClick={() => setCancelService(s)}
>
<XCircle className="h-3.5 w-3.5" /> 取消服务
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* 取消服务确认弹窗 */}
<Dialog open={!!cancelService} onOpenChange={(o) => !o && setCancelService(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>取消服务关系</DialogTitle>
<DialogDescription>
确认取消与用户「{cancelService ? maskPhone(cancelService.user_phone || '') : ''}」的服务关系?取消后不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelService(null)}>再想想</Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
{cancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
确认取消
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkbenchShell>
)
}
+5
View File
@@ -0,0 +1,5 @@
/** 专家工作台页面集合 barrel 导出 */
export { WorkbenchPendingServices } from './WorkbenchPendingServices'
export { WorkbenchPending } from './WorkbenchPending'
export { WorkbenchReplied } from './WorkbenchReplied'
export { WorkbenchUsers } from './WorkbenchUsers'
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState, useCallback } from 'react'
import { toast } from 'sonner'
import { Briefcase, Loader2, Send, MessageSquare, FileText, Check } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState'
import { Markdown } from '@/components/common/Markdown'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'
import { api } from '@/lib/api'
import type { Expert, ExpertService, Consultation } from '@/types'
/** 脱敏手机号 */
export function maskPhone(phone: string): string {
if (!phone || phone === 'unknown') return '未知用户'
if (phone.length >= 8) return phone.slice(0, 3) + '****' + phone.slice(-4)
return phone
}
/** 专家工作台数据加载 hook */
export function useWorkbenchData() {
const [expert, setExpert] = useState<Expert | null>(null)
const [services, setServices] = useState<ExpertService[]>([])
const [consultations, setConsultations] = useState<Consultation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await api.expertWorkbench()
setExpert(res.expert)
setServices(res.services)
setConsultations(res.consultations)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadData() }, [loadData])
return { expert, services, consultations, loading, error, loadData }
}
/** 回复弹窗 */
export function ReplyDialog({
target,
onClose,
onSuccess,
}: {
target: Consultation | null
onClose: () => void
onSuccess: () => void
}) {
const [replyText, setReplyText] = useState('')
const [replying, setReplying] = useState(false)
const handleReply = async () => {
if (!target || !replyText.trim()) return
setReplying(true)
try {
await api.replyConsultation(target.id, replyText.trim())
toast.success('回复已发送')
onClose()
setReplyText('')
onSuccess()
} catch (e) {
toast.error(e instanceof Error ? e.message : '回复失败')
} finally {
setReplying(false)
}
}
return (
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>回复咨询</DialogTitle>
<DialogDescription>{target?.topic}</DialogDescription>
</DialogHeader>
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="输入你的专业回复…"
className="min-h-[120px]"
/>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleReply} disabled={replying || !replyText.trim()}>
{replying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
发送回复
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 咨询详情弹窗 */
export function ConsultationDetailDialog({
target,
onClose,
}: {
target: Consultation | null
onClose: () => void
}) {
return (
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>咨询详情</DialogTitle>
<DialogDescription>{target?.topic}</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
{/* 用户问题 */}
<div>
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" /> 用户问题
</p>
<div className="rounded-md bg-muted/30 p-3 text-sm">
<Markdown content={target?.question || ''} />
</div>
</div>
{/* AI 上下文 */}
{target?.context && (
<div>
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<FileText className="h-3.5 w-3.5" /> AI 对话上下文
</p>
<div className="rounded-md bg-muted/20 p-3 text-xs text-muted-foreground">
<Markdown content={target.context} />
</div>
</div>
)}
{/* 专家回复 */}
<div>
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Check className="h-3.5 w-3.5 text-emerald-500" /> 专家回复
</p>
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-sm">
<Markdown content={target?.reply || ''} />
</div>
<p className="mt-1.5 text-xs text-muted-foreground">回复时间:{target?.replied_at}</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 工作台页面外壳:处理 loading/error/非专家的通用逻辑 */
export function WorkbenchShell({
expert,
loading,
error,
title,
description,
children,
}: {
expert: Expert | null
loading: boolean
error: string | null
title: string
description?: string
children: React.ReactNode
}) {
if (loading) {
return (
<PageLayout title={title} description={description}>
<LoadingState />
</PageLayout>
)
}
if (error) {
return (
<PageLayout title={title} description={description}>
<EmptyState title="无法访问" description={error} icon={<Briefcase className="h-8 w-8" />} />
</PageLayout>
)
}
if (!expert) {
return (
<PageLayout title={title} description={description}>
<EmptyState title="您不是注册专家" description="请联系管理员开通专家账号" icon={<Briefcase className="h-8 w-8" />} />
</PageLayout>
)
}
return (
<PageLayout title={title} description={description || `${expert.name} · ${expert.title}`}>
{children}
</PageLayout>
)
}