feat: BossAi 老板AI助手初始版本
包含功能: - 法务参谋/经营决策/生活助理/情绪中心四大AI对话模块 - 专家咨询系统(用户选定→专家确认→咨询→回复→评价) - 角色登录(用户/专家/管理员)+ ProtectedRoute 权限控制 - 管理后台(用户管理/专家管理/AI使用统计/专家活跃度) - 专家工作台(待确认服务/待回复咨询/已回复咨询/服务中用户) - 服务关系取消(用户和专家均可取消,需自己确认) - 全页面移动端响应式适配 - 知识库RAG检索 - 对话历史(普通/保密模式) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AppLayout } from '@/components/layout/AppLayout'
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
import { LegalAdvisor } from '@/pages/LegalAdvisor'
|
||||
import { Decisions } from '@/pages/Decisions'
|
||||
import { LifeAssistant } from '@/pages/LifeAssistant'
|
||||
import { EmotionCenter } from '@/pages/EmotionCenter'
|
||||
import { Settings } from '@/pages/Settings'
|
||||
import { KnowledgeBase } from '@/pages/KnowledgeBase'
|
||||
import { Experts } from '@/pages/Experts'
|
||||
import { WorkbenchPendingServices, WorkbenchPending, WorkbenchReplied, WorkbenchUsers } from '@/pages/workbench'
|
||||
import { ChatHistory } from '@/pages/ChatHistory'
|
||||
import { AdminStats, AdminAiUsage, AdminExpertActivity, AdminUsers, AdminExperts } from '@/pages/admin'
|
||||
import { Placeholder } from '@/pages/Placeholder'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { isLoggedIn, getRole, type Role } from '@/lib/auth'
|
||||
|
||||
/** 路由守卫:未登录跳转登录页,角色不匹配跳转各自首页 */
|
||||
function ProtectedRoute({ children, allowedRoles }: { children: React.ReactNode; allowedRoles?: Role[] }) {
|
||||
if (!isLoggedIn()) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
const currentRole = getRole()
|
||||
if (allowedRoles && !allowedRoles.includes(currentRole)) {
|
||||
// 角色不匹配,跳转到各自首页
|
||||
const homeMap: Record<Role, string> = { user: '/', expert: '/workbench', admin: '/admin' }
|
||||
return <Navigate to={homeMap[currentRole]} replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<AppLayout />}>
|
||||
{/* 用户页面:仅用户可访问 */}
|
||||
<Route index element={<ProtectedRoute allowedRoles={['user']}><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="legal" element={<ProtectedRoute allowedRoles={['user']}><LegalAdvisor /></ProtectedRoute>} />
|
||||
<Route path="decisions" element={<ProtectedRoute allowedRoles={['user']}><Decisions /></ProtectedRoute>} />
|
||||
<Route path="life" element={<ProtectedRoute allowedRoles={['user']}><LifeAssistant /></ProtectedRoute>} />
|
||||
<Route path="emotion" element={<ProtectedRoute allowedRoles={['user']}><EmotionCenter /></ProtectedRoute>} />
|
||||
<Route path="knowledge" element={<ProtectedRoute allowedRoles={['user']}><KnowledgeBase /></ProtectedRoute>} />
|
||||
<Route path="experts" element={<ProtectedRoute allowedRoles={['user']}><Experts /></ProtectedRoute>} />
|
||||
<Route path="history" element={<ProtectedRoute allowedRoles={['user']}><ChatHistory /></ProtectedRoute>} />
|
||||
<Route path="settings" element={<ProtectedRoute allowedRoles={['user']}><Settings /></ProtectedRoute>} />
|
||||
{/* 专家工作台:仅专家可访问,拆分为独立页面 */}
|
||||
<Route path="workbench" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchPendingServices /></ProtectedRoute>} />
|
||||
<Route path="workbench/pending" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchPending /></ProtectedRoute>} />
|
||||
<Route path="workbench/replied" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchReplied /></ProtectedRoute>} />
|
||||
<Route path="workbench/users" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchUsers /></ProtectedRoute>} />
|
||||
{/* 管理后台:仅管理员可访问,拆分为独立页面 */}
|
||||
<Route path="admin" element={<ProtectedRoute allowedRoles={['admin']}><AdminStats /></ProtectedRoute>} />
|
||||
<Route path="admin/ai-usage" element={<ProtectedRoute allowedRoles={['admin']}><AdminAiUsage /></ProtectedRoute>} />
|
||||
<Route path="admin/expert-activity" element={<ProtectedRoute allowedRoles={['admin']}><AdminExpertActivity /></ProtectedRoute>} />
|
||||
<Route path="admin/users" element={<ProtectedRoute allowedRoles={['admin']}><AdminUsers /></ProtectedRoute>} />
|
||||
<Route path="admin/experts" element={<ProtectedRoute allowedRoles={['admin']}><AdminExperts /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Placeholder title="页面未找到" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Send,
|
||||
Upload,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Lock,
|
||||
Flame,
|
||||
Users,
|
||||
HelpCircle,
|
||||
RefreshCw,
|
||||
Check,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { RiskCardView } from './RiskCardView'
|
||||
import { Markdown } from './Markdown'
|
||||
import { EmptyState } from './EmptyState'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AskResponse, EmotionMode, ExpertService, Scene, SessionMode } from '@/types'
|
||||
|
||||
export interface ChatPanelMode {
|
||||
value: SessionMode
|
||||
label: string
|
||||
desc: string
|
||||
Icon: typeof MessageSquare
|
||||
}
|
||||
|
||||
export interface ChatPanelProps {
|
||||
/** 业务场景,路由后端 system prompt */
|
||||
scene: Scene
|
||||
/** 示例问题 */
|
||||
samples: string[]
|
||||
/** 当前话题(用于 AI 换一批时定向生成) */
|
||||
topic?: string
|
||||
/** 允许的会话模式(默认全部) */
|
||||
modes?: ChatPanelMode[]
|
||||
/** 默认会话模式 */
|
||||
defaultMode?: SessionMode
|
||||
/** 情绪模式(仅 emotion 场景),由页面控制并传入 */
|
||||
emotionMode?: EmotionMode
|
||||
/** 情绪模式选择器(由页面渲染,通过 children 注入) */
|
||||
modeSelector?: React.ReactNode
|
||||
/** 输入框占位符 */
|
||||
placeholder?: string
|
||||
/** 是否显示风险卡片(法务场景显示,其他场景可选) */
|
||||
showRiskCard?: boolean
|
||||
/** 文件上传支持 */
|
||||
enableUpload?: boolean
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
risk?: AskResponse['risk']
|
||||
answer?: string | null
|
||||
model?: string
|
||||
inferenceLocation?: string
|
||||
fallbackUsed?: boolean
|
||||
/** 流式输出中 */
|
||||
streaming?: boolean
|
||||
/** 事实追问回答(样式区分) */
|
||||
isFactAnswer?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MODES: ChatPanelMode[] = [
|
||||
{ value: 'normal', label: '普通模式', desc: '日常经营,可保存历史', Icon: MessageSquare },
|
||||
{ value: 'confidential', label: '保密模式', desc: '加密保存,文件自动过期', Icon: Lock },
|
||||
{ value: 'ephemeral', label: '阅后即焚', desc: '退出即销毁,不留痕迹', Icon: Flame },
|
||||
]
|
||||
|
||||
/** 通用对话面板:会话创建 → 提问 → 风险卡/答复 → 文件上传 → 隐私急停 */
|
||||
export function ChatPanel({
|
||||
scene,
|
||||
samples,
|
||||
topic,
|
||||
modes = DEFAULT_MODES,
|
||||
defaultMode = 'confidential',
|
||||
emotionMode,
|
||||
modeSelector,
|
||||
placeholder = '描述你的问题…',
|
||||
showRiskCard = true,
|
||||
enableUpload = true,
|
||||
}: ChatPanelProps) {
|
||||
const [mode, setMode] = useState<SessionMode>(defaultMode)
|
||||
const [sid, setSid] = useState<string | null>(null)
|
||||
const [question, setQuestion] = useState('')
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dynamicSamples, setDynamicSamples] = useState<string[]>(samples)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
/** 切换话题时重置为内置示例 */
|
||||
useEffect(() => {
|
||||
setDynamicSamples(samples)
|
||||
}, [samples])
|
||||
const [destroying, setDestroying] = useState(false)
|
||||
const [confirmDestroy, setConfirmDestroy] = useState(false)
|
||||
/** 专家咨询弹窗 */
|
||||
const [expertConsultOpen, setExpertConsultOpen] = useState(false)
|
||||
const [expertList, setExpertList] = useState<ExpertService[]>([])
|
||||
const [selectedExpert, setSelectedExpert] = useState<ExpertService | null>(null)
|
||||
const [consultQuestion, setConsultQuestion] = useState('')
|
||||
const [consultSupplement, setConsultSupplement] = useState('')
|
||||
const [consulting, setConsulting] = useState(false)
|
||||
/** 追问状态 */
|
||||
const [factQuestions, setFactQuestions] = useState<Array<{ dim_key: string; dim_desc: string; question: string }>>([])
|
||||
const [factAnswers, setFactAnswers] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
|
||||
const [factInputs, setFactInputs] = useState<Record<string, string>>({})
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
/** 待切换的目标模式(销毁确认后切换) */
|
||||
const [pendingMode, setPendingMode] = useState<SessionMode | null>(null)
|
||||
/** 当前分析中的用户问题(避免从 messages 闭包取旧值) */
|
||||
const currentQuestionRef = useRef('')
|
||||
|
||||
const ensureSession = async () => {
|
||||
if (sid) return sid
|
||||
const res = await api.createSession(mode)
|
||||
setSid(res.session_id)
|
||||
toast.success(`已创建${modes.find((m) => m.value === mode)?.label}会话`)
|
||||
return res.session_id
|
||||
}
|
||||
|
||||
/** 流式输出 AI 分析结论(事实已充分后调用) */
|
||||
const streamAnalysis = async (currentSid: string, userQuestion: string, answers: Array<{ dim_key: string; dim_desc: string; answer: string }>) => {
|
||||
// 用 ref 保存 assistant 消息 index,避免闭包旧值
|
||||
const assistantIndexRef = { current: 0 }
|
||||
setMessages((prev) => {
|
||||
assistantIndexRef.current = prev.length
|
||||
return [...prev, { role: 'assistant', content: '', streaming: true }]
|
||||
})
|
||||
|
||||
let accumulated = ''
|
||||
let riskCard: AskResponse['risk'] | undefined
|
||||
let doneData: Partial<AskResponse> = {}
|
||||
|
||||
await api.askStream(currentSid, userQuestion, scene, emotionMode, {
|
||||
onRisk: (risk) => {
|
||||
riskCard = risk
|
||||
setMessages((prev) => {
|
||||
const next = [...prev]
|
||||
const idx = assistantIndexRef.current
|
||||
if (next[idx]) {
|
||||
next[idx] = { ...next[idx], risk: showRiskCard ? risk : undefined }
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
onDelta: (content) => {
|
||||
accumulated += content
|
||||
setMessages((prev) => {
|
||||
const next = [...prev]
|
||||
const idx = assistantIndexRef.current
|
||||
if (next[idx]) {
|
||||
next[idx] = { ...next[idx], content: accumulated }
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
onDone: (data) => {
|
||||
doneData = data
|
||||
setMessages((prev) => {
|
||||
const next = [...prev]
|
||||
const idx = assistantIndexRef.current
|
||||
if (next[idx]) {
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
content: accumulated || doneData.answer || riskCard?.conclusion || '',
|
||||
streaming: false,
|
||||
answer: doneData.answer,
|
||||
model: doneData.model,
|
||||
inferenceLocation: doneData.inference_location,
|
||||
fallbackUsed: doneData.fallback_used,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev]
|
||||
const idx = assistantIndexRef.current
|
||||
if (next[idx]) {
|
||||
next[idx] = { ...next[idx], content: accumulated || `⚠️ ${error}`, streaming: false }
|
||||
}
|
||||
return next
|
||||
})
|
||||
toast.error(error)
|
||||
},
|
||||
}, answers.length > 0 ? answers : undefined)
|
||||
}
|
||||
|
||||
/** AI 换一批示例问题 */
|
||||
const handleRefreshSamples = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const res = await api.generateSamples(scene, topic, dynamicSamples)
|
||||
setDynamicSamples(res.samples)
|
||||
} catch {
|
||||
toast.error('换一批失败,请稍后重试')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAsk = async () => {
|
||||
if (!question.trim()) {
|
||||
toast.error('请输入问题')
|
||||
return
|
||||
}
|
||||
const userQuestion = question.trim()
|
||||
setLoading(true)
|
||||
setQuestion('')
|
||||
setFactAnswers([])
|
||||
setFactQuestions([])
|
||||
setFactInputs({})
|
||||
currentQuestionRef.current = userQuestion
|
||||
|
||||
// 添加用户消息
|
||||
setMessages((prev) => [...prev, { role: 'user', content: userQuestion }])
|
||||
|
||||
try {
|
||||
const currentSid = await ensureSession()
|
||||
|
||||
// 第1步:分析问题,判断是否需要追问
|
||||
setAnalyzing(true)
|
||||
const analysis = await api.askAnalyze(currentSid, userQuestion, scene, emotionMode)
|
||||
setAnalyzing(false)
|
||||
|
||||
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
|
||||
// 需要追问:显示追问问题
|
||||
setFactQuestions(analysis.questions)
|
||||
// 不继续流式输出,等待用户回答
|
||||
return
|
||||
}
|
||||
|
||||
// 不需要追问,直接流式输出分析结论
|
||||
await streamAnalysis(currentSid, userQuestion, [])
|
||||
} catch (e) {
|
||||
setAnalyzing(false)
|
||||
toast.error(e instanceof Error ? e.message : '提问失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户提交追问回答 */
|
||||
const handleFactSubmit = async () => {
|
||||
// 收集回答
|
||||
const newAnswers = factQuestions
|
||||
.map((q) => ({
|
||||
dim_key: q.dim_key,
|
||||
dim_desc: q.dim_desc,
|
||||
answer: factInputs[q.dim_key]?.trim() || '',
|
||||
}))
|
||||
.filter((a) => a.answer)
|
||||
|
||||
if (newAnswers.length === 0) {
|
||||
toast.error('请至少回答一个问题')
|
||||
return
|
||||
}
|
||||
|
||||
const allAnswers = [...factAnswers, ...newAnswers]
|
||||
setFactAnswers(allAnswers)
|
||||
setLoading(true)
|
||||
|
||||
// 添加用户回答到消息列表
|
||||
const answerText = newAnswers.map((a) => `**${a.dim_desc}**: ${a.answer}`).join('\n')
|
||||
setMessages((prev) => [...prev, { role: 'user', content: answerText, isFactAnswer: true }])
|
||||
|
||||
try {
|
||||
const currentSid = sid || (await ensureSession())
|
||||
setAnalyzing(true)
|
||||
const analysis = await api.askAnalyze(
|
||||
currentSid,
|
||||
currentQuestionRef.current,
|
||||
scene,
|
||||
emotionMode,
|
||||
allAnswers,
|
||||
)
|
||||
setAnalyzing(false)
|
||||
|
||||
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
|
||||
// 还需追问
|
||||
setFactQuestions(analysis.questions)
|
||||
setFactInputs({})
|
||||
return
|
||||
}
|
||||
|
||||
// 事实充分,流式输出分析结论
|
||||
setFactQuestions([])
|
||||
setFactInputs({})
|
||||
const originalQuestion = currentQuestionRef.current
|
||||
await streamAnalysis(currentSid, originalQuestion, allAnswers)
|
||||
} catch (e) {
|
||||
setAnalyzing(false)
|
||||
toast.error(e instanceof Error ? e.message : '追问失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDestroy = async () => {
|
||||
if (!sid) return
|
||||
setDestroying(true)
|
||||
try {
|
||||
await api.destroy(sid)
|
||||
setSid(null)
|
||||
setMessages([])
|
||||
setFactAnswers([])
|
||||
setFactQuestions([])
|
||||
setFactInputs({})
|
||||
// 如果是模式切换触发的销毁,切换到目标模式
|
||||
if (pendingMode) {
|
||||
setMode(pendingMode)
|
||||
setPendingMode(null)
|
||||
toast.success(`已切换到${modes.find((m) => m.value === pendingMode)?.label}模式`)
|
||||
} else {
|
||||
toast.success('会话已销毁,所有内容已清除')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '销毁失败')
|
||||
} finally {
|
||||
setDestroying(false)
|
||||
setConfirmDestroy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开专家咨询弹窗:加载该场景分类的专家 */
|
||||
const openExpertConsult = async (topic: string, supplement: string) => {
|
||||
setConsultQuestion(topic)
|
||||
setConsultSupplement(supplement)
|
||||
setExpertConsultOpen(true)
|
||||
setSelectedExpert(null)
|
||||
try {
|
||||
// 只加载已确认服务(active)的专家
|
||||
const res = await api.myServices()
|
||||
const activeServices = res.results.filter(s => s.status === 'active')
|
||||
setExpertList(activeServices)
|
||||
} catch {
|
||||
toast.error('加载服务专家失败')
|
||||
setExpertList([])
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送咨询给专家 */
|
||||
const handleConsult = async () => {
|
||||
if (!selectedExpert) {
|
||||
toast.error('请选择一位专家')
|
||||
return
|
||||
}
|
||||
setConsulting(true)
|
||||
try {
|
||||
// 获取最近的 AI 回复作为上下文
|
||||
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant' && m.answer)
|
||||
const context = lastAssistant?.answer || ''
|
||||
// 咨询内容 = 问题 + 补充内容(直接换行拼接)
|
||||
const fullQuestion = consultSupplement.trim()
|
||||
? `${consultQuestion}\n\n${consultSupplement.trim()}`
|
||||
: consultQuestion
|
||||
await api.createConsultation({
|
||||
expert_id: selectedExpert.expert_id,
|
||||
topic: consultQuestion.slice(0, 200),
|
||||
context: context.slice(0, 500),
|
||||
question: fullQuestion,
|
||||
})
|
||||
toast.success(`已发送给${selectedExpert.expert_name},请到专家库查看回复`)
|
||||
setExpertConsultOpen(false)
|
||||
setSelectedExpert(null)
|
||||
setConsultQuestion('')
|
||||
setConsultSupplement('')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '发送失败,请先选定专家')
|
||||
} finally {
|
||||
setConsulting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !sid) {
|
||||
if (!sid) toast.error('请先创建会话再上传文件')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.uploadFile(sid, file)
|
||||
toast.success(`文件已进入临时会话区: ${res.filename}`)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'user', content: `[上传文件] ${res.filename}` },
|
||||
{ role: 'assistant', content: res.message + (res.text_preview ? `\n\n预览:\n${res.text_preview}` : '') },
|
||||
])
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '上传失败')
|
||||
} finally {
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const switchMode = (m: SessionMode) => {
|
||||
if (sid) {
|
||||
setPendingMode(m)
|
||||
setConfirmDestroy(true)
|
||||
return
|
||||
}
|
||||
setMode(m)
|
||||
}
|
||||
|
||||
const currentMode = modes.find((m) => m.value === mode)!
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">对话</CardTitle>
|
||||
<Dialog open={confirmDestroy} onOpenChange={setConfirmDestroy}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={mode === 'ephemeral' ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!sid || destroying}
|
||||
>
|
||||
{destroying ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{mode === 'ephemeral' ? '销毁会话' : '隐私急停'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{pendingMode ? `切换到${modes.find((m) => m.value === pendingMode)?.label}模式?` : '确认销毁当前会话?'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingMode
|
||||
? `切换模式将销毁当前会话,切换到${modes.find((m) => m.value === pendingMode)?.label}模式。`
|
||||
: '销毁后将立即清除会话密钥和所有消息内容,不可恢复。'
|
||||
}
|
||||
若涉及正式合同、诉讼或劳动争议,可能需要依法留档。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setConfirmDestroy(false); setPendingMode(null) }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDestroy}>
|
||||
确认销毁
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 会话模式选择 */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => switchMode(m.value)}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-md border p-3 text-left transition-colors',
|
||||
mode === m.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<m.Icon className="h-4 w-4" aria-hidden />
|
||||
<span className="text-sm font-medium">{m.label}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{m.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 场景特定选择器(如情绪模式) */}
|
||||
{modeSelector}
|
||||
|
||||
{sid && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<currentMode.Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{currentMode.label}会话已建立
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 对话区 */}
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState
|
||||
title="开始提问"
|
||||
description="选择模式后输入问题,系统会给出结构化建议。"
|
||||
action={
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{dynamicSamples.map((s) => (
|
||||
<Button key={s} variant="outline" size="sm" onClick={() => setQuestion(s)}>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshSamples}
|
||||
disabled={refreshing}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
换一批
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('flex gap-3', msg.role === 'user' ? 'justify-end' : 'justify-start')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg p-3 text-sm',
|
||||
msg.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{msg.risk ? (
|
||||
<RiskCardView
|
||||
card={msg.risk}
|
||||
answer={msg.answer || msg.content}
|
||||
model={msg.model}
|
||||
inferenceLocation={msg.inferenceLocation}
|
||||
fallbackUsed={msg.fallbackUsed}
|
||||
streaming={msg.streaming}
|
||||
onEscalate={() => {
|
||||
// 原始问题 = 第一条非追问的 user 消息
|
||||
const originalQuestion = messages.find((m) => m.role === 'user' && !m.isFactAnswer)
|
||||
// 补充内容 = 追问回答(去掉 ** 标记)
|
||||
const factAnswers = messages.filter((m) => m.isFactAnswer)
|
||||
const supplement = factAnswers
|
||||
.map((m) => m.content.replace(/\*\*/g, ''))
|
||||
.join('\n')
|
||||
openExpertConsult(
|
||||
originalQuestion?.content || '',
|
||||
supplement,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : msg.role === 'user' && !msg.isFactAnswer ? (
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
) : msg.isFactAnswer ? (
|
||||
<Markdown
|
||||
content={msg.content}
|
||||
streaming={msg.streaming}
|
||||
className="prose-headings:text-primary-foreground prose-p:text-primary-foreground prose-li:text-primary-foreground prose-strong:text-primary-foreground"
|
||||
/>
|
||||
) : (
|
||||
<Markdown content={msg.content} streaming={msg.streaming} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 事实追问区 */}
|
||||
{factQuestions.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-900">
|
||||
<HelpCircle className="h-4 w-4" aria-hidden />
|
||||
请补充以下信息,以便更准确地分析
|
||||
</div>
|
||||
{factQuestions.map((q) => (
|
||||
<div key={q.dim_key} className="space-y-1.5">
|
||||
<Label className="text-xs text-amber-800">{q.question}</Label>
|
||||
<Input
|
||||
value={factInputs[q.dim_key] || ''}
|
||||
onChange={(e) => setFactInputs((prev) => ({ ...prev, [q.dim_key]: e.target.value }))}
|
||||
placeholder="请输入…"
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// 跳过追问,直接分析
|
||||
setFactQuestions([])
|
||||
setFactInputs({})
|
||||
setLoading(true)
|
||||
const originalQuestion = currentQuestionRef.current
|
||||
ensureSession()
|
||||
.then((currentSid) => streamAnalysis(currentSid, originalQuestion, factAnswers))
|
||||
.catch((e) => toast.error(e instanceof Error ? e.message : '分析失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
跳过,直接分析
|
||||
</Button>
|
||||
<Button onClick={handleFactSubmit} disabled={loading} size="sm">
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
提交补充
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分析中提示 */}
|
||||
{analyzing && (
|
||||
<div className="flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在分析问题…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label htmlFor="question">你的问题</Label>
|
||||
<Textarea
|
||||
id="question"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleAsk()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{enableUpload && (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
accept=".txt,.md,.csv,.json"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={!sid}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" /> 上传文件
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">⌘/Ctrl + Enter 发送</span>
|
||||
<Button onClick={handleAsk} disabled={loading || !question.trim()} size="sm">
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
)}
|
||||
分析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 专家咨询弹窗 */}
|
||||
<Dialog open={expertConsultOpen} onOpenChange={setExpertConsultOpen}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-primary" /> 发给专家咨询
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
将此话题发给已确认服务关系的专家,获取人工专业意见。仅显示已同意为你服务的专家。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 已确认服务的专家列表 */}
|
||||
<div>
|
||||
<Label className="mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
||||
选择专家
|
||||
</Label>
|
||||
{expertList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-6 text-center">
|
||||
<Users className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">暂无已确认服务的专家</p>
|
||||
<p className="text-xs text-muted-foreground/70">请先到专家库选定专家并等待确认</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{expertList.map((s) => (
|
||||
<button
|
||||
key={s.service_id}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border p-2.5 text-left transition',
|
||||
selectedExpert?.service_id === s.service_id
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:bg-muted/40',
|
||||
)}
|
||||
onClick={() => setSelectedExpert(s)}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-medium text-primary">
|
||||
{s.expert_name?.[0] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{s.expert_name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{s.expert_title} · {s.expert_bio}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">服务中</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 咨询问题 */}
|
||||
<div>
|
||||
<Label htmlFor="consult-q" className="mb-1.5 flex items-center gap-1.5 text-sm font-medium">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-primary" />
|
||||
咨询问题
|
||||
</Label>
|
||||
<Textarea
|
||||
id="consult-q"
|
||||
value={consultQuestion}
|
||||
onChange={(e) => setConsultQuestion(e.target.value)}
|
||||
placeholder="描述你想咨询的具体问题…"
|
||||
className="min-h-[56px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 补充内容 */}
|
||||
<div>
|
||||
<Label htmlFor="consult-sup" className="mb-1.5 flex items-center gap-1.5 text-sm font-medium">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
补充内容
|
||||
<span className="text-xs font-normal text-muted-foreground/70">(可选)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="consult-sup"
|
||||
value={consultSupplement}
|
||||
onChange={(e) => setConsultSupplement(e.target.value)}
|
||||
placeholder="补充背景信息、具体细节或期望专家关注的重点…"
|
||||
className="min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setExpertConsultOpen(false)}>取消</Button>
|
||||
<Button onClick={handleConsult} disabled={consulting || !selectedExpert || !consultQuestion.trim()}>
|
||||
{consulting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
发送咨询
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 空数据状态 */
|
||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center', className)}>
|
||||
<div className="mb-3 text-muted-foreground/60">
|
||||
{icon ?? <Inbox className="h-8 w-8" aria-hidden />}
|
||||
</div>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description && <p className="mt-1 max-w-sm text-xs text-muted-foreground">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ErrorStateProps {
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 错误状态:带重试按钮 */
|
||||
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center rounded-lg border border-rose-200 bg-rose-50 py-8 text-center', className)}>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-rose-600" aria-hidden />
|
||||
<p className="text-sm font-medium text-rose-900">{message}</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* 专家头像:优先显示 avatar_url 图片,加载失败或无图片时 fallback 到首字母头像。
|
||||
*
|
||||
* @param name 专家姓名(用于 fallback 首字母)
|
||||
* @param avatarUrl 头像 URL(可空)
|
||||
* @param size 尺寸:'sm'(32px) | 'md'(40px) | 'lg'(80px) | 'xl'(128px) | '2xl'(160px)
|
||||
* @param shape 形状:'circle'(圆形) | 'rect'(圆角矩形)
|
||||
* @param className 额外样式
|
||||
*/
|
||||
export function ExpertAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
size = 'md',
|
||||
shape = 'circle',
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
avatarUrl?: string | null
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
shape?: 'circle' | 'rect'
|
||||
className?: string
|
||||
}) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const showImg = avatarUrl && !imgError
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'h-8 w-8 text-xs',
|
||||
md: 'h-10 w-10 text-sm',
|
||||
lg: 'h-20 w-20 text-xl',
|
||||
xl: 'h-32 w-32 text-3xl',
|
||||
'2xl': 'h-40 w-40 text-4xl',
|
||||
}[size]
|
||||
|
||||
const shapeClass = shape === 'circle' ? 'rounded-full' : 'rounded-lg'
|
||||
|
||||
if (showImg) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl!}
|
||||
alt={name}
|
||||
onError={() => setImgError(true)}
|
||||
className={cn('object-cover ring-2 ring-primary/10', shapeClass, sizeClass, className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center bg-primary/10 font-medium text-primary',
|
||||
shapeClass,
|
||||
sizeClass,
|
||||
className,
|
||||
)}
|
||||
aria-label={`${name}的头像`}
|
||||
>
|
||||
{name?.[0] || '?'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 加载状态:带 spinner 和可选文案 */
|
||||
export function LoadingState({ label = '加载中…', className }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground', className)}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Markdown 渲染组件:支持 GFM(表格/列表/删除线)+ 代码高亮。
|
||||
|
||||
* 用于 AI 回复的富文本展示,替代纯 whitespace-pre-wrap。
|
||||
* 流式输出时传入 streaming=true 显示光标。
|
||||
*/
|
||||
export function Markdown({
|
||||
content,
|
||||
streaming,
|
||||
className,
|
||||
}: {
|
||||
content: string
|
||||
streaming?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm max-w-none dark:prose-invert',
|
||||
// 继承父级文字色,不被 prose 默认色覆盖
|
||||
'prose-headings:text-foreground prose-p:text-foreground prose-li:text-foreground',
|
||||
'prose-strong:text-foreground prose-a:text-primary',
|
||||
// 紧凑间距
|
||||
'prose-headings:my-2 prose-headings:first:mt-0 prose-p:my-2',
|
||||
'prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5',
|
||||
'prose-pre:my-3 prose-code:before:content-none prose-code:after:content-none',
|
||||
'prose-table:my-3 prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
// 代码块:圆角 + 暗色背景
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
className="rounded-lg bg-slate-900 p-3 text-xs text-slate-100 overflow-x-auto"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// 行内代码:浅灰背景
|
||||
code: ({ children, className, ...props }) => {
|
||||
const isInline = !className?.includes('language-')
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-muted px-1 py-0.5 text-xs font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
// 表格:外层包裹 overflow-x-auto 防止小屏溢出
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full min-w-full border-collapse border text-xs"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="border bg-muted/50 font-semibold text-left"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td className="border" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
// 链接:新标签打开
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content + (streaming && content && !content.endsWith('\n') ? '\n' : '')}
|
||||
</ReactMarkdown>
|
||||
{streaming && (
|
||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-current align-text-bottom" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { WifiOff } from 'lucide-react'
|
||||
import { network } from '@/lib/api'
|
||||
|
||||
/** 全局离线指示器:网络断开时顶部显示提示条 */
|
||||
export function OfflineIndicator() {
|
||||
const [online, setOnline] = useState(network.isOnline())
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = network.onChange((isOnline) => setOnline(isOnline))
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
if (online) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="fixed top-0 left-0 right-0 z-[60] flex items-center justify-center gap-2 bg-amber-500 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
<WifiOff className="h-4 w-4" aria-hidden />
|
||||
<span>网络已断开,部分功能不可用</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageLayoutProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 页面布局:标题 + 描述 + 操作区 + 内容区 */
|
||||
export function PageLayout({ title, description, actions, children, className }: PageLayoutProps) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { AlertOctagon, FileText, ListChecks, Users, Cloud, Cpu, AlertTriangle } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Markdown } from './Markdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RiskCard } from '@/types'
|
||||
|
||||
const BORDER: Record<string, string> = {
|
||||
green: 'border-l-emerald-500',
|
||||
amber: 'border-l-amber-500',
|
||||
orange: 'border-l-orange-500',
|
||||
red: 'border-l-rose-500',
|
||||
}
|
||||
|
||||
interface RiskCardViewProps {
|
||||
card: RiskCard
|
||||
answer?: string | null
|
||||
model?: string
|
||||
inferenceLocation?: string
|
||||
fallbackUsed?: boolean
|
||||
/** 流式输出中(显示光标) */
|
||||
streaming?: boolean
|
||||
onEscalate?: () => void
|
||||
}
|
||||
|
||||
/** 风险卡片:等级 + 结论 + 行动 + 证据 + 升级提示 + 模型答复 + 推理位置 */
|
||||
export function RiskCardView({ card, answer, model, inferenceLocation, fallbackUsed, streaming, onEscalate }: RiskCardViewProps) {
|
||||
return (
|
||||
<Card className={cn('border-l-4', BORDER[card.level])}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">风险判断</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{inferenceLocation && (
|
||||
<span className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
fallbackUsed ? 'text-amber-600' : 'text-muted-foreground',
|
||||
)}>
|
||||
{inferenceLocation === 'cloud' ? <Cloud className="h-3 w-3" /> : <Cpu className="h-3 w-3" />}
|
||||
{inferenceLocation === 'cloud' ? '云端推理' : '本地推理'}
|
||||
{fallbackUsed && <AlertTriangle className="h-3 w-3" />}
|
||||
</span>
|
||||
)}
|
||||
{model && !inferenceLocation && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model === 'kimi' ? '云端模型' : '本地分诊'}
|
||||
</span>
|
||||
)}
|
||||
<StatusBadge level={card.level} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm leading-relaxed">{card.conclusion}</p>
|
||||
|
||||
{card.actions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<ListChecks className="h-3.5 w-3.5" aria-hidden /> 立即行动
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{card.actions.map((a, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-foreground/40" />
|
||||
{a}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.evidence.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden /> 需保存证据
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">{card.evidence.join('、')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answer && (
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<h4 className="mb-2 text-xs font-semibold text-muted-foreground">模型分析</h4>
|
||||
<Markdown content={answer} streaming={streaming} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.escalation && (
|
||||
<div className="flex flex-wrap items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<AlertOctagon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-900">建议人工专业复核</p>
|
||||
<p className="mt-0.5 text-xs text-amber-700">
|
||||
本事项可能造成明显损失或涉及重大风险,AI 输出不替代执业律师/会计师正式意见。
|
||||
</p>
|
||||
</div>
|
||||
{onEscalate && (
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={onEscalate}>
|
||||
<Users className="h-3.5 w-3.5" /> 问专家
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AlertTriangle, ShieldAlert, ShieldCheck, CircleAlert } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RiskLevel } from '@/types'
|
||||
|
||||
const MAP: Record<RiskLevel, { label: string; cls: string; Icon: typeof ShieldCheck }> = {
|
||||
green: { label: '绿色', cls: 'bg-emerald-100 text-emerald-700 border-emerald-200', Icon: ShieldCheck },
|
||||
amber: { label: '黄色', cls: 'bg-amber-100 text-amber-700 border-amber-200', Icon: CircleAlert },
|
||||
orange: { label: '橙色', cls: 'bg-orange-100 text-orange-700 border-orange-200', Icon: AlertTriangle },
|
||||
red: { label: '红色', cls: 'bg-rose-100 text-rose-700 border-rose-200', Icon: ShieldAlert },
|
||||
}
|
||||
|
||||
/** 风险等级徽章:统一呈现 green/amber/orange/red */
|
||||
export function StatusBadge({ level, className }: { level: RiskLevel; className?: string }) {
|
||||
const { label, cls, Icon } = MAP[level]
|
||||
return (
|
||||
<Badge variant="outline" className={cn(cls, 'gap-1', className)}>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{label}风险
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { MobileNav } from './MobileNav'
|
||||
import { OfflineIndicator } from '@/components/common/OfflineIndicator'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
/** 应用主布局:左 Sidebar + 顶部 Header + 内容区 */
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8f9fb]">
|
||||
<OfflineIndicator />
|
||||
<Sidebar />
|
||||
<div className="md:pl-60">
|
||||
<Header nav={<MobileNav />} />
|
||||
<main className="container mx-auto max-w-7xl px-4 py-6 md:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Menu, X, ShieldCheck, LogOut } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearAuth, getName, getPhone } from '@/lib/auth'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
'/': '驾驶舱',
|
||||
'/legal': '法务参谋',
|
||||
'/decisions': '经营决策',
|
||||
'/life': '生活助理',
|
||||
'/emotion': '情绪中心',
|
||||
'/knowledge': '知识库',
|
||||
'/experts': '专家库',
|
||||
'/workbench': '待确认服务',
|
||||
'/workbench/pending': '待回复咨询',
|
||||
'/workbench/replied': '已回复咨询',
|
||||
'/workbench/users': '服务中用户',
|
||||
'/admin': '管理后台',
|
||||
'/settings': '设置',
|
||||
}
|
||||
|
||||
/** 顶部 Header:sticky,移动端含抽屉触发 */
|
||||
export function Header({ nav }: { nav: React.ReactNode }) {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const title = TITLES[pathname] ?? 'BossAi'
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
toast.success('已退出登录')
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const userName = getName() || getPhone()?.slice(-4) || ''
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-14 border-b bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80">
|
||||
<div className="flex h-14 items-center gap-3 px-4 md:px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
aria-label="打开菜单"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">{title}</h1>
|
||||
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-600" aria-hidden />
|
||||
<span className="hidden sm:inline">隐私优先 · 本地/边缘/云端</span>
|
||||
{userName && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-foreground/70">{userName}</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout} aria-label="退出登录">
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline ml-1">退出</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端抽屉 */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 md:hidden">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setOpen(false)} />
|
||||
<div className={cn('absolute left-0 top-0 h-full w-60 bg-card p-3 shadow-lg')}>
|
||||
<div className="flex h-11 items-center justify-between px-2">
|
||||
<span className="font-semibold">BossAi</span>
|
||||
<Button variant="ghost" size="icon" aria-label="关闭菜单" onClick={() => setOpen(false)}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div onClick={() => setOpen(false)}>{nav}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Scale,
|
||||
TrendingUp,
|
||||
Heart,
|
||||
Smile,
|
||||
Settings,
|
||||
BookOpen,
|
||||
Users,
|
||||
Briefcase,
|
||||
History,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Activity,
|
||||
Check,
|
||||
Star,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getRole, type Role } from '@/lib/auth'
|
||||
|
||||
/** 全部导航项(与 Sidebar 保持一致) */
|
||||
const ALL_NAV = [
|
||||
{ to: '/', label: '驾驶舱', icon: LayoutDashboard, end: true, roles: ['user'] as Role[] },
|
||||
{ to: '/legal', label: '法务参谋', icon: Scale, roles: ['user'] as Role[] },
|
||||
{ to: '/decisions', label: '经营决策', icon: TrendingUp, roles: ['user'] as Role[] },
|
||||
{ to: '/life', label: '生活助理', icon: Heart, roles: ['user'] as Role[] },
|
||||
{ to: '/emotion', label: '情绪中心', icon: Smile, roles: ['user'] as Role[] },
|
||||
{ to: '/knowledge', label: '知识库', icon: BookOpen, roles: ['user'] as Role[] },
|
||||
{ to: '/experts', label: '专家库', icon: Users, roles: ['user'] as Role[] },
|
||||
{ to: '/history', label: '对话历史', icon: History, roles: ['user'] as Role[] },
|
||||
// 专家工作台菜单项(仅专家可见,拆分为独立页面)
|
||||
{ to: '/workbench', label: '待确认服务', icon: Check, end: true, roles: ['expert'] as Role[] },
|
||||
{ to: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, roles: ['expert'] as Role[] },
|
||||
{ to: '/workbench/replied', label: '已回复咨询', icon: Star, roles: ['expert'] as Role[] },
|
||||
{ to: '/workbench/users', label: '服务中用户', icon: Briefcase, roles: ['expert'] as Role[] },
|
||||
// 管理后台菜单项(仅管理员可见)
|
||||
{ to: '/admin', label: '平台统计', icon: BarChart3, end: true, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/ai-usage', label: 'AI 使用', icon: MessageSquare, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/expert-activity', label: '专家沟通', icon: Activity, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/users', label: '用户管理', icon: Users, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/experts', label: '专家管理', icon: Briefcase, roles: ['admin'] as Role[] },
|
||||
{ to: '/settings', label: '设置', icon: Settings, roles: ['user'] as Role[] },
|
||||
]
|
||||
|
||||
/** 移动端抽屉内导航:按角色过滤 */
|
||||
export function MobileNav() {
|
||||
const currentRole = getRole()
|
||||
const nav = ALL_NAV.filter((item) => item.roles.includes(currentRole))
|
||||
return (
|
||||
<nav className="mt-2 space-y-1" aria-label="移动端导航">
|
||||
{nav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Scale,
|
||||
TrendingUp,
|
||||
Heart,
|
||||
Smile,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
BookOpen,
|
||||
Users,
|
||||
Briefcase,
|
||||
History,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Activity,
|
||||
Check,
|
||||
Star,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getRole, type Role } from '@/lib/auth'
|
||||
|
||||
/** 全部导航项 */
|
||||
const ALL_NAV = [
|
||||
{ to: '/', label: '驾驶舱', icon: LayoutDashboard, end: true, roles: ['user'] as Role[] },
|
||||
{ to: '/legal', label: '法务参谋', icon: Scale, roles: ['user'] as Role[] },
|
||||
{ to: '/decisions', label: '经营决策', icon: TrendingUp, roles: ['user'] as Role[] },
|
||||
{ to: '/life', label: '生活助理', icon: Heart, roles: ['user'] as Role[] },
|
||||
{ to: '/emotion', label: '情绪中心', icon: Smile, roles: ['user'] as Role[] },
|
||||
{ to: '/knowledge', label: '知识库', icon: BookOpen, roles: ['user'] as Role[] },
|
||||
{ to: '/experts', label: '专家库', icon: Users, roles: ['user'] as Role[] },
|
||||
{ to: '/history', label: '对话历史', icon: History, roles: ['user'] as Role[] },
|
||||
// 专家工作台菜单项(仅专家可见,拆分为独立页面)
|
||||
{ to: '/workbench', label: '待确认服务', icon: Check, end: true, roles: ['expert'] as Role[] },
|
||||
{ to: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, roles: ['expert'] as Role[] },
|
||||
{ to: '/workbench/replied', label: '已回复咨询', icon: Star, roles: ['expert'] as Role[] },
|
||||
{ to: '/workbench/users', label: '服务中用户', icon: Briefcase, roles: ['expert'] as Role[] },
|
||||
// 管理后台菜单项(仅管理员可见)
|
||||
{ to: '/admin', label: '平台统计', icon: BarChart3, end: true, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/ai-usage', label: 'AI 使用', icon: MessageSquare, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/expert-activity', label: '专家沟通', icon: Activity, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/users', label: '用户管理', icon: Users, roles: ['admin'] as Role[] },
|
||||
{ to: '/admin/experts', label: '专家管理', icon: Briefcase, roles: ['admin'] as Role[] },
|
||||
{ to: '/settings', label: '设置', icon: Settings, roles: ['user'] as Role[] },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const currentRole = getRole()
|
||||
const nav = ALL_NAV.filter((item) => item.roles.includes(currentRole))
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 md:border-r md:bg-card">
|
||||
<div className="flex h-14 items-center gap-2 border-b px-5">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" aria-hidden />
|
||||
<span className="font-semibold tracking-tight">BossAi</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">BossAi</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-3" aria-label="主导航">
|
||||
{nav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t p-4 text-xs text-muted-foreground">
|
||||
<p>老板敢问,系统敢忘</p>
|
||||
<p className="mt-1">看得见的智能,看不见的留痕</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-emerald-100 text-emerald-700',
|
||||
warning: 'border-transparent bg-amber-100 text-amber-700',
|
||||
danger: 'border-transparent bg-rose-100 text-rose-700',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-normal sm:whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-4 sm:p-6', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-4 sm:p-6', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-4 pt-0 sm:p-6 sm:pt-0', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Toaster as Sonner } from 'sonner'
|
||||
|
||||
/** 全局 toast 反馈,替代 alert/confirm */
|
||||
const Toaster = () => (
|
||||
<Sonner
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,73 @@
|
||||
/* 代码高亮主题(github-dark) */
|
||||
@import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--primary: 222 47% 11%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 16% 93%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 210 16% 95%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--accent: 210 16% 93%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214 20% 90%;
|
||||
--input: 214 20% 90%;
|
||||
--ring: 222 47% 11%;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 13%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222 47% 13%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 217 33% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217 33% 20%;
|
||||
--input: 217 33% 20%;
|
||||
--ring: 212 95% 68%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式:隐藏导航,卡片去阴影 */
|
||||
@media print {
|
||||
header, nav, .no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.card {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid hsl(var(--border)) !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import type {
|
||||
AskResponse,
|
||||
EmotionMode,
|
||||
Expert,
|
||||
ExpertService,
|
||||
Consultation,
|
||||
FileInspectResponse,
|
||||
HistoryMessage,
|
||||
HistorySession,
|
||||
KnowledgeDoc,
|
||||
Scene,
|
||||
SessionMode,
|
||||
SessionResponse,
|
||||
Settings,
|
||||
} from '@/types'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
/** 请求超时时间(ms),弱网时自动延长 */
|
||||
const DEFAULT_TIMEOUT = 30000
|
||||
const LONG_TIMEOUT = 60000 // ask 等慢接口
|
||||
|
||||
/** 网络状态检测 */
|
||||
export const network = {
|
||||
isOnline: () => typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||
onChange: (cb: (online: boolean) => void) => {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
const handler = () => cb(navigator.onLine)
|
||||
window.addEventListener('online', handler)
|
||||
window.addEventListener('offline', handler)
|
||||
return () => {
|
||||
window.removeEventListener('online', handler)
|
||||
window.removeEventListener('offline', handler)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** 带 Authorization header 的请求 */
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('bossai_token') : null
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
/** 带超时的 fetch */
|
||||
async function fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeout)
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal })
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('请求超时,请检查网络后重试')
|
||||
}
|
||||
if (!navigator.onLine) {
|
||||
throw new Error('网络已断开,请检查网络连接')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** 带重试的请求(网络错误/超时重试,429 限流不重试) */
|
||||
async function requestWithRetry<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
options?: { timeout?: number; retries?: number },
|
||||
): Promise<T> {
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||
const maxRetries = options?.retries ?? 1
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (!navigator.onLine) {
|
||||
throw new Error('网络已断开,请检查网络连接')
|
||||
}
|
||||
try {
|
||||
const isFormData = init?.body instanceof FormData
|
||||
const res = await fetchWithTimeout(BASE + path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...authHeaders(),
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
}, timeout)
|
||||
if (res.status === 429) {
|
||||
const retryAfter = res.headers.get('Retry-After')
|
||||
const wait = retryAfter ? parseInt(retryAfter, 10) : 2
|
||||
throw new Error(`请求过于频繁,请 ${wait} 秒后重试`)
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
// FastAPI HTTPException 返回 {detail: "字符串"},422 校验错误返回 {detail: [{msg: "..."}]}
|
||||
let msg: string
|
||||
if (typeof body.detail === 'string') {
|
||||
msg = body.detail
|
||||
} else if (Array.isArray(body.detail) && body.detail.length > 0) {
|
||||
msg = body.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join('; ')
|
||||
} else {
|
||||
msg = `请求失败 ${res.status}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
} catch (err) {
|
||||
lastError = err as Error
|
||||
// 超时或网络错误才重试,业务错误不重试
|
||||
const isRetryable = lastError.message.includes('超时') ||
|
||||
lastError.message.includes('网络') || lastError.message.includes('Failed to fetch')
|
||||
if (!isRetryable || attempt === maxRetries) {
|
||||
throw lastError
|
||||
}
|
||||
// 指数退避:1s, 2s
|
||||
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
throw lastError!
|
||||
}
|
||||
|
||||
/** 兼容旧接口 */
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return requestWithRetry<T>(path, init)
|
||||
}
|
||||
|
||||
/** API 客户端:所有模型调用经后端完成,前端不持有密钥 */
|
||||
export const api = {
|
||||
health: () => request<{ status: string; service: string }>('/health'),
|
||||
|
||||
/** AI 生成示例问题(换一批) */
|
||||
generateSamples: (
|
||||
scene: Scene = 'legal',
|
||||
topic?: string,
|
||||
existing?: string[],
|
||||
) =>
|
||||
request<{ samples: string[] }>('/samples', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ scene, topic, existing }),
|
||||
}),
|
||||
|
||||
createSession: (mode: SessionMode) =>
|
||||
request<SessionResponse>('/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
|
||||
ask: (sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode) =>
|
||||
requestWithRetry<AskResponse>(`/sessions/${sid}/ask`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ question, scene, emotion_mode: emotionMode }),
|
||||
}, { timeout: LONG_TIMEOUT, retries: 0 }), // ask 不重试(可能产生重复调用)
|
||||
|
||||
/** 注册 */
|
||||
register: (phone: string, password: string, name: string = '') =>
|
||||
request<{ phone: string; name: string; token: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, password, name }),
|
||||
}),
|
||||
|
||||
/** 登录 */
|
||||
login: (phone: string, password: string) =>
|
||||
request<{ phone: string; name: string; token: string; role?: string; expert_id?: number; admin_id?: number }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, password }),
|
||||
}),
|
||||
|
||||
/** 获取用户画像 */
|
||||
getProfile: () =>
|
||||
requestWithRetry<Record<string, unknown>>('/profile/me', {}),
|
||||
|
||||
/** 事实追问分析:返回追问问题或标记事实已充分 */
|
||||
askAnalyze: (
|
||||
sid: string,
|
||||
question: string,
|
||||
scene: Scene = 'legal',
|
||||
emotionMode?: EmotionMode,
|
||||
factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>,
|
||||
) =>
|
||||
request<{
|
||||
need_ask: boolean
|
||||
need_analysis: boolean
|
||||
round: number
|
||||
questions?: Array<{ dim_key: string; dim_desc: string; question: string }>
|
||||
fact_summary?: string
|
||||
question_type: string
|
||||
risk_level: string
|
||||
}>(`/sessions/${sid}/ask-analyze`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
question,
|
||||
scene,
|
||||
emotion_mode: emotionMode,
|
||||
fact_answers: factAnswers,
|
||||
}),
|
||||
}),
|
||||
|
||||
/** 流式提问:SSE 接收风险卡 + 逐 token 答复
|
||||
* 回调:
|
||||
* - onRisk: 收到风险卡
|
||||
* - onDelta: 收到 token 片段
|
||||
* - onDone: 流式完成,返回完整响应
|
||||
* - onError: 错误
|
||||
*/
|
||||
askStream: async (
|
||||
sid: string,
|
||||
question: string,
|
||||
scene: Scene = 'legal',
|
||||
emotionMode?: EmotionMode,
|
||||
callbacks?: {
|
||||
onRisk?: (risk: AskResponse['risk']) => void
|
||||
onDelta?: (content: string) => void
|
||||
onDone?: (data: Partial<AskResponse>) => void
|
||||
onError?: (error: string) => void
|
||||
},
|
||||
factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>,
|
||||
) => {
|
||||
if (!navigator.onLine) {
|
||||
callbacks?.onError?.('网络已断开,请检查网络连接')
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), LONG_TIMEOUT)
|
||||
try {
|
||||
const res = await fetch(`${BASE}/sessions/${sid}/ask-stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ question, scene, emotion_mode: emotionMode, fact_answers: factAnswers }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
callbacks?.onError?.(detail.detail || `请求失败 ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
callbacks?.onError?.('浏览器不支持流式读取')
|
||||
return
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
// SSE 事件以 \n\n 分隔
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop() || ''
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue
|
||||
let eventType = 'message'
|
||||
let dataLine = ''
|
||||
for (const line of part.split('\n')) {
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7)
|
||||
else if (line.startsWith('data: ')) dataLine = line.slice(6)
|
||||
}
|
||||
if (!dataLine) continue
|
||||
try {
|
||||
const data = JSON.parse(dataLine)
|
||||
if (eventType === 'risk') callbacks?.onRisk?.(data)
|
||||
else if (eventType === 'delta') callbacks?.onDelta?.(data.content || '')
|
||||
else if (eventType === 'done') callbacks?.onDone?.(data)
|
||||
else if (eventType === 'error') callbacks?.onError?.(data.detail || '未知错误')
|
||||
} catch {
|
||||
// 忽略解析失败的事件
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
callbacks?.onError?.('请求超时,请检查网络后重试')
|
||||
} else {
|
||||
callbacks?.onError?.(err instanceof Error ? err.message : '流式请求失败')
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
|
||||
destroy: (sid: string) =>
|
||||
request<{ destroyed: boolean; session_id: string }>(
|
||||
`/sessions/${sid}/destroy`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
|
||||
uploadFile: async (sid: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await fetch(`${BASE}/sessions/${sid}/file`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(detail.detail || `上传失败 ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<FileInspectResponse>
|
||||
},
|
||||
|
||||
addKnowledge: (data: {
|
||||
title: string
|
||||
content: string
|
||||
source?: string
|
||||
doc_type?: string
|
||||
region?: string | null
|
||||
effective_date?: string | null
|
||||
keywords?: string | null
|
||||
}) =>
|
||||
request<KnowledgeDoc>('/knowledge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
listKnowledge: (docType?: string) =>
|
||||
request<{ results: KnowledgeDoc[] }>(
|
||||
`/knowledge${docType ? `?doc_type=${docType}` : ''}`,
|
||||
),
|
||||
|
||||
searchKnowledge: (q: string, docType?: string) =>
|
||||
request<{ results: KnowledgeDoc[] }>(
|
||||
`/knowledge/search?q=${encodeURIComponent(q)}${docType ? `&doc_type=${docType}` : ''}`,
|
||||
),
|
||||
|
||||
deleteKnowledge: (id: number) =>
|
||||
request<{ deleted: boolean; id: number }>(`/knowledge/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// ===== 用户设置 =====
|
||||
getSettings: () => request<{ settings: Settings }>('/settings'),
|
||||
|
||||
updateSettings: (settings: Settings) =>
|
||||
request<{ updated: boolean; settings: Settings }>('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ settings }),
|
||||
}),
|
||||
|
||||
// ===== 专家库(用户端) =====
|
||||
listExperts: (category?: string) =>
|
||||
request<{ results: Expert[] }>(`/experts${category ? `?category=${category}` : ''}`),
|
||||
|
||||
getExpert: (id: number) => request<Expert>(`/experts/${id}`),
|
||||
|
||||
selectExpert: (expertId: number) =>
|
||||
request<{ service_id: number; status: string }>(`/experts/${expertId}/select`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
myServices: () => request<{ results: ExpertService[] }>('/my/services'),
|
||||
|
||||
myConsultations: (status?: string) =>
|
||||
request<{ results: Consultation[] }>(`/my/consultations${status ? `?status=${status}` : ''}`),
|
||||
|
||||
createConsultation: (data: { expert_id: number; topic: string; context?: string; question?: string }) =>
|
||||
request<{ consultation_id: number; created_at: string; status: string }>('/consultations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
rateConsultation: (consultationId: number, rating: number, comment: string = '') =>
|
||||
request<{ rating_id: number; created_at: string }>(`/consultations/${consultationId}/rate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ consultation_id: consultationId, rating, comment }),
|
||||
}),
|
||||
|
||||
// ===== 专家工作台 =====
|
||||
expertWorkbench: () =>
|
||||
request<{ expert: Expert; services: ExpertService[]; consultations: Consultation[] }>('/expert/workbench'),
|
||||
|
||||
acceptService: (serviceId: number) =>
|
||||
request<{ accepted: boolean }>(`/expert/services/${serviceId}/accept`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
/** 用户取消服务关系 */
|
||||
cancelMyService: (serviceId: number) =>
|
||||
request<{ cancelled: boolean }>(`/my/services/${serviceId}/cancel`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
/** 专家取消服务关系 */
|
||||
cancelExpertService: (serviceId: number) =>
|
||||
request<{ cancelled: boolean }>(`/expert/services/${serviceId}/cancel`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
replyConsultation: (consultationId: number, reply: string) =>
|
||||
request<{ replied: boolean }>(`/expert/consultations/${consultationId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reply }),
|
||||
}),
|
||||
|
||||
// ===== 管理后台 =====
|
||||
adminListExperts: (status?: string) =>
|
||||
request<{ results: Expert[] }>(`/admin/experts${status ? `?status=${status}` : ''}`),
|
||||
|
||||
adminUpdateExpertStatus: (expertId: number, status: 'active' | 'inactive') =>
|
||||
request<{ updated: boolean; expert_id: number; status: string }>(`/admin/experts/${expertId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
|
||||
/** 管理端:编辑专家信息 */
|
||||
adminUpdateExpert: (expertId: number, data: Partial<Pick<Expert, 'name' | 'category' | 'title' | 'bio' | 'expertise' | 'avatar_url' | 'status'>>) =>
|
||||
request<{ updated: boolean; expert: Expert }>(`/admin/experts/${expertId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
/** 管理端:上传专家头像(返回 avatar_url) */
|
||||
adminUploadExpertAvatar: (expertId: number, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return requestWithRetry<{ avatar_url: string }>(`/admin/experts/${expertId}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// FormData 不能设 Content-Type,浏览器自动设 multipart
|
||||
headers: {},
|
||||
}, { timeout: 30000, retries: 0 })
|
||||
},
|
||||
|
||||
adminListUsers: () => request<{ results: Array<{ id: number; phone: string; name: string; created_at: string }> }>('/admin/users'),
|
||||
|
||||
adminStats: () => request<{
|
||||
users: number
|
||||
experts_active: number
|
||||
experts_total: number
|
||||
services_active: number
|
||||
consultations_total: number
|
||||
consultations_pending: number
|
||||
}>('/admin/stats'),
|
||||
|
||||
/** 管理端:AI 使用统计(不含具体内容) */
|
||||
adminAiUsage: () => request<{
|
||||
total_questions: number
|
||||
active_users: number
|
||||
by_scene: Array<{ scene: string; count: number }>
|
||||
by_mode: Array<{ mode: string; count: number }>
|
||||
by_risk: Array<{ level: string; count: number }>
|
||||
by_inference: Array<{ location: string; count: number }>
|
||||
daily_trend: Array<{ date: string; count: number }>
|
||||
by_user: Array<{ phone_masked: string; questions: number; replies: number; last_active: string | null; top_scene: string | null }>
|
||||
}>('/admin/ai-usage'),
|
||||
|
||||
/** 管理端:专家沟通统计(不含具体内容) */
|
||||
adminExpertActivity: () => request<{
|
||||
total: number
|
||||
replied: number
|
||||
pending: number
|
||||
by_status: Array<{ status: string; count: number }>
|
||||
by_category: Array<{ category: string; count: number }>
|
||||
by_expert: Array<{ name: string; category: string; title: string; total: number; replied: number; pending: number; avg_rating: number; rating_count: number }>
|
||||
daily_trend: Array<{ date: string; count: number }>
|
||||
by_user: Array<{ phone_masked: string; consultations: number; last_active: string | null }>
|
||||
}>('/admin/expert-activity'),
|
||||
|
||||
// ===== 脱敏行动摘要 =====
|
||||
previewSummary: (summary: Record<string, unknown>) =>
|
||||
request<{ savable_fields: string[]; preview: Record<string, unknown> }>('/summaries/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ summary }),
|
||||
}),
|
||||
|
||||
saveSummary: (sessionId: string | null, summary: Record<string, unknown>) =>
|
||||
request<{ id: number; summary: Record<string, unknown> }>('/summaries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sessionId, summary }),
|
||||
}),
|
||||
|
||||
listSummaries: (limit?: number) =>
|
||||
request<{ results: Array<{ id: number; session_id: string | null; summary: Record<string, unknown>; created_at: string }> }>(
|
||||
`/summaries${limit ? `?limit=${limit}` : ''}`,
|
||||
),
|
||||
|
||||
// ===== 向量检索 =====
|
||||
vectorSearchKnowledge: (q: string, limit?: number) =>
|
||||
request<{ results: KnowledgeDoc[] }>(
|
||||
`/knowledge/vector-search?q=${encodeURIComponent(q)}${limit ? `&limit=${limit}` : ''}`,
|
||||
),
|
||||
|
||||
// ===== 销毁状态 =====
|
||||
destroyStatus: (sid: string) =>
|
||||
request<{ session_id: string; destroyed: boolean; destroy_log: Array<Record<string, unknown>> }>(
|
||||
`/sessions/${sid}/destroy-status`,
|
||||
),
|
||||
|
||||
// ===== 可观测性 =====
|
||||
getMetrics: () => request<Record<string, unknown>>('/metrics'),
|
||||
|
||||
// ===== 对话历史 =====
|
||||
/** 列出历史会话(可选模式过滤) */
|
||||
listHistorySessions: (mode?: 'normal' | 'confidential') =>
|
||||
request<{ results: HistorySession[] }>(
|
||||
`/history/sessions${mode ? `?mode=${mode}` : ''}`,
|
||||
),
|
||||
|
||||
/** 获取会话历史消息(普通模式直接返回明文,保密模式返回 need_verify) */
|
||||
getHistoryMessages: (sessionId: string) =>
|
||||
request<{
|
||||
session_id: string
|
||||
mode: 'normal' | 'confidential'
|
||||
need_verify: boolean
|
||||
message_count?: number
|
||||
messages?: HistoryMessage[]
|
||||
}>(`/history/sessions/${sessionId}`),
|
||||
|
||||
/** 保密模式:验证密码后获取解密消息 */
|
||||
verifyHistory: (sessionId: string, password: string) =>
|
||||
request<{
|
||||
session_id: string
|
||||
mode: 'confidential'
|
||||
verified: boolean
|
||||
messages: HistoryMessage[]
|
||||
}>(`/history/sessions/${sessionId}/verify`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
|
||||
/** 删除会话历史 */
|
||||
deleteHistory: (sessionId: string) =>
|
||||
request<{ deleted: boolean; session_id: string; count: number }>(
|
||||
`/history/sessions/${sessionId}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** 用户认证:token 管理 + 登录状态 */
|
||||
|
||||
const TOKEN_KEY = 'bossai_token'
|
||||
const PHONE_KEY = 'bossai_phone'
|
||||
const NAME_KEY = 'bossai_name'
|
||||
const ROLE_KEY = 'bossai_role'
|
||||
|
||||
export type Role = 'user' | 'expert' | 'admin'
|
||||
|
||||
/** 获取 token */
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** 获取手机号 */
|
||||
export function getPhone(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(PHONE_KEY)
|
||||
}
|
||||
|
||||
/** 获取用户名 */
|
||||
export function getName(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(NAME_KEY)
|
||||
}
|
||||
|
||||
/** 获取角色 */
|
||||
export function getRole(): Role {
|
||||
if (typeof window === 'undefined') return 'user'
|
||||
return (localStorage.getItem(ROLE_KEY) as Role) || 'user'
|
||||
}
|
||||
|
||||
/** 保存登录信息 */
|
||||
export function setAuth(token: string, phone: string, name: string = '', role: Role = 'user') {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(PHONE_KEY, phone)
|
||||
if (name) localStorage.setItem(NAME_KEY, name)
|
||||
localStorage.setItem(ROLE_KEY, role)
|
||||
}
|
||||
|
||||
/** 清除登录信息 */
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(PHONE_KEY)
|
||||
localStorage.removeItem(NAME_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
}
|
||||
|
||||
/** 是否已登录 */
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/** 合并 Tailwind 类名,处理冲突 */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
History,
|
||||
Lock,
|
||||
Trash2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HistorySession, HistoryMessage, SessionMode } from '@/types'
|
||||
|
||||
/** 模式标签配置 */
|
||||
const MODE_CONFIG: Record<SessionMode, { label: string; icon: typeof History; color: string }> = {
|
||||
normal: { label: '普通模式', icon: MessageSquare, color: 'bg-blue-100 text-blue-700' },
|
||||
confidential: { label: '保密模式', icon: Lock, color: 'bg-amber-100 text-amber-700' },
|
||||
ephemeral: { label: '阅后即焚', icon: Shield, color: 'bg-rose-100 text-rose-700' },
|
||||
}
|
||||
|
||||
/** 风险等级颜色 */
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
green: 'bg-green-100 text-green-700',
|
||||
amber: 'bg-yellow-100 text-yellow-700',
|
||||
orange: 'bg-orange-100 text-orange-700',
|
||||
red: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
/** 场景中文映射 */
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
legal: '法务参谋',
|
||||
decision: '经营决策',
|
||||
life: '生活助理',
|
||||
emotion: '情绪中心',
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function ChatHistory() {
|
||||
const [sessions, setSessions] = useState<HistorySession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'normal' | 'confidential'>('all')
|
||||
|
||||
// 当前查看的会话
|
||||
const [currentSession, setCurrentSession] = useState<HistorySession | null>(null)
|
||||
const [messages, setMessages] = useState<HistoryMessage[]>([])
|
||||
const [msgLoading, setMsgLoading] = useState(false)
|
||||
const [msgError, setMsgError] = useState<string | null>(null)
|
||||
const [needVerify, setNeedVerify] = useState(false)
|
||||
|
||||
// 保密模式密码验证
|
||||
const [verifyPassword, setVerifyPassword] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [deleteTarget, setDeleteTarget] = useState<HistorySession | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const mode = modeFilter === 'all' ? undefined : modeFilter
|
||||
const res = await api.listHistorySessions(mode)
|
||||
setSessions(res.results)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [modeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [loadSessions])
|
||||
|
||||
/** 查看会话历史 */
|
||||
const handleView = useCallback(async (session: HistorySession) => {
|
||||
setCurrentSession(session)
|
||||
setMessages([])
|
||||
setMsgError(null)
|
||||
setNeedVerify(false)
|
||||
setVerifyPassword('')
|
||||
setMsgLoading(true)
|
||||
try {
|
||||
const res = await api.getHistoryMessages(session.session_id)
|
||||
if (res.need_verify) {
|
||||
setNeedVerify(true)
|
||||
} else {
|
||||
setMessages(res.messages || [])
|
||||
}
|
||||
} catch (err) {
|
||||
setMsgError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setMsgLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 保密模式密码验证 */
|
||||
const handleVerify = useCallback(async () => {
|
||||
if (!currentSession || !verifyPassword) return
|
||||
setVerifying(true)
|
||||
setMsgError(null)
|
||||
try {
|
||||
const res = await api.verifyHistory(currentSession.session_id, verifyPassword)
|
||||
setMessages(res.messages)
|
||||
setNeedVerify(false)
|
||||
toast.success('验证通过,已解密历史消息')
|
||||
} catch (err) {
|
||||
setMsgError(err instanceof Error ? err.message : '验证失败')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}, [currentSession, verifyPassword])
|
||||
|
||||
/** 删除会话历史 */
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.deleteHistory(deleteTarget.session_id)
|
||||
toast.success('已删除该会话历史')
|
||||
setDeleteTarget(null)
|
||||
// 如果删除的是当前查看的会话,返回列表
|
||||
if (currentSession?.session_id === deleteTarget.session_id) {
|
||||
setCurrentSession(null)
|
||||
}
|
||||
loadSessions()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除失败')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTarget, currentSession, loadSessions])
|
||||
|
||||
/** 返回列表 */
|
||||
const handleBack = useCallback(() => {
|
||||
setCurrentSession(null)
|
||||
setMessages([])
|
||||
setNeedVerify(false)
|
||||
setVerifyPassword('')
|
||||
}, [])
|
||||
|
||||
// ===== 会话详情视图 =====
|
||||
if (currentSession) {
|
||||
const modeCfg = MODE_CONFIG[currentSession.mode]
|
||||
return (
|
||||
<PageLayout
|
||||
title="对话历史"
|
||||
description="查看和管理历史对话记录"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(currentSession)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex items-center gap-3 border-b pb-3">
|
||||
<modeCfg.icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{modeCfg.label}</span>
|
||||
{currentSession.scene && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{SCENE_LABELS[currentSession.scene] || currentSession.scene}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
|
||||
{' · '}{currentSession.message_count} 条消息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保密模式密码验证 */
|
||||
needVerify ? (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<div className="mb-4 flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
||||
<Lock className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">保密模式历史</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
该会话为加密存储,请输入登录密码验证后查看
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="verify-pwd" className="text-xs">登录密码</Label>
|
||||
<Input
|
||||
id="verify-pwd"
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleVerify()}
|
||||
placeholder="请输入登录密码"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{msgError && <p className="text-xs text-destructive">{msgError}</p>}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleVerify}
|
||||
disabled={verifying || !verifyPassword}
|
||||
>
|
||||
{verifying ? (
|
||||
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />验证中</>
|
||||
) : (
|
||||
'验证并查看'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : msgLoading ? (
|
||||
<LoadingState label="加载历史消息..." />
|
||||
) : msgError ? (
|
||||
<ErrorState message={msgError} onRetry={() => handleView(currentSession)} />
|
||||
) : messages.length === 0 ? (
|
||||
<EmptyState icon={<MessageSquare className="h-8 w-8" />} title="暂无消息" description="该会话没有历史记录" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 会话列表视图 =====
|
||||
return (
|
||||
<PageLayout
|
||||
title="对话历史"
|
||||
description="普通模式可直接查看,保密模式需密码验证,阅后即焚不落库"
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-md border bg-card p-1">
|
||||
{(['all', 'normal', 'confidential'] as const).map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={modeFilter === m ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setModeFilter(m)}
|
||||
>
|
||||
{m === 'all' ? '全部' : m === 'normal' ? '普通' : '保密'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingState label="加载历史会话..." />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadSessions} />
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<History className="h-8 w-8" />}
|
||||
title="暂无历史会话"
|
||||
description="普通模式和保密模式的对话会保存在这里,阅后即焚不落库"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const modeCfg = MODE_CONFIG[session.mode]
|
||||
return (
|
||||
<Card
|
||||
key={session.session_id}
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={() => handleView(session)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', modeCfg.color)}>
|
||||
<modeCfg.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{modeCfg.label}</span>
|
||||
{session.scene && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{SCENE_LABELS[session.scene] || session.scene}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.message_count} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{formatTime(session.last_message_at)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单条消息气泡 */
|
||||
function MessageBubble({ message }: { message: HistoryMessage }) {
|
||||
const isUser = message.role === 'user'
|
||||
const riskLevel = message.risk?.level
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('max-w-[80%] rounded-lg p-3', isUser ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
|
||||
{/* 消息正文 */}
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
|
||||
{/* AI 回复的风险卡信息 */}
|
||||
{!isUser && riskLevel && (
|
||||
<div className="mt-2 space-y-1 border-t border-border/30 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn('text-xs', RISK_COLORS[riskLevel])}>
|
||||
{riskLevel === 'green' ? '低风险' : riskLevel === 'amber' ? '需补证' : riskLevel === 'orange' ? '建议复核' : '高风险'}
|
||||
</Badge>
|
||||
{message.risk?.escalation && (
|
||||
<Badge variant="destructive" className="text-xs">建议升级</Badge>
|
||||
)}
|
||||
{message.meta?.inference_location && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
{message.meta.inference_location === 'cloud' ? '云端' : '本地'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{message.risk?.actions && message.risk.actions.length > 0 && (
|
||||
<ul className="text-xs opacity-80">
|
||||
{message.risk.actions.map((a, i) => (
|
||||
<li key={i}>• {a}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间戳 */}
|
||||
<p className={cn('mt-1 text-[10px]', isUser ? 'text-primary-foreground/60' : 'text-muted-foreground')}>
|
||||
{formatTime(message.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 删除确认弹窗 */
|
||||
function DeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
deleting,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
deleting: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除对话历史</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除该会话的所有历史消息吗?此操作不可恢复。
|
||||
{open && ' 保密模式的加密数据将一并清除。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={deleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={deleting}>
|
||||
{deleting ? (
|
||||
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />删除中</>
|
||||
) : (
|
||||
'确认删除'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Scale,
|
||||
TrendingUp,
|
||||
Heart,
|
||||
Smile,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
interface HealthState {
|
||||
status: 'ok' | 'loading' | 'error'
|
||||
service?: string
|
||||
}
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ to: '/legal', label: '法务参谋', desc: '劳动用工、合同审查、催款违约', icon: Scale },
|
||||
{ to: '/decisions', label: '经营决策', desc: '项目承接、扩张收缩、融资合伙', icon: TrendingUp },
|
||||
{ to: '/life', label: '生活助理', desc: '家庭、健康、出差、重要事项', icon: Heart },
|
||||
{ to: '/emotion', label: '情绪中心', desc: '冷静分析、陪伴疏导、强力推动', icon: Smile },
|
||||
]
|
||||
|
||||
/** 驾驶舱:今日概览 + 快速入口 + 系统状态 */
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const [health, setHealth] = useState<HealthState>({ status: 'loading' })
|
||||
|
||||
const checkHealth = async () => {
|
||||
setHealth({ status: 'loading' })
|
||||
try {
|
||||
const res = await api.health()
|
||||
setHealth({ status: 'ok', service: res.service })
|
||||
} catch {
|
||||
setHealth({ status: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="驾驶舱"
|
||||
description="今日风险、待办与快速入口。老板敢问,系统敢忘。"
|
||||
>
|
||||
{/* 系统状态条 */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-emerald-100">
|
||||
<ShieldCheck className="h-5 w-5 text-emerald-600" aria-hidden />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">系统状态</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{health.status === 'loading' && '正在检查后端服务…'}
|
||||
{health.status === 'ok' && `后端服务正常 · ${health.service}`}
|
||||
{health.status === 'error' && '后端服务不可达,请确认 API 已启动'}
|
||||
</p>
|
||||
</div>
|
||||
{health.status === 'error' && (
|
||||
<Button variant="outline" size="sm" onClick={checkHealth}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 今日最该做的一件事 */}
|
||||
<Card className="border-l-4 border-l-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-amber-500" aria-hidden />
|
||||
今天最该做的一件事
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
检查本周是否有即将到期的合同续签、员工试用期届满或应收账款逾期。
|
||||
进入「法务参谋」可快速评估风险并生成行动清单。
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => navigate('/legal')}
|
||||
>
|
||||
前往法务参谋 <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 快速入口 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">快速入口</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{QUICK_ENTRIES.map((entry) => (
|
||||
<Card
|
||||
key={entry.to}
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={() => navigate(entry.to)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(entry.to)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-secondary">
|
||||
<entry.icon className="h-5 w-5 text-foreground" aria-hidden />
|
||||
</div>
|
||||
<p className="font-medium">{entry.label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{entry.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 风险等级说明 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity className="h-4 w-4" aria-hidden />
|
||||
风险分级说明
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ level: '绿色', desc: '一般信息查询,可自助处理', cls: 'bg-emerald-50 border-emerald-200' },
|
||||
{ level: '黄色', desc: '事实不完整,需补证', cls: 'bg-amber-50 border-amber-200' },
|
||||
{ level: '橙色', desc: '可能明显损失,建议复核', cls: 'bg-orange-50 border-orange-200' },
|
||||
{ level: '红色', desc: '诉讼/刑事/重大税务,必须升级', cls: 'bg-rose-50 border-rose-200' },
|
||||
].map((r) => (
|
||||
<div key={r.level} className={`rounded-md border p-3 ${r.cls}`}>
|
||||
<p className="text-sm font-semibold">{r.level}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{r.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{health.status === 'error' && (
|
||||
<ErrorState message="无法连接后端服务,请确认 uvicorn 已在 8000 端口运行" onRetry={checkHealth} />
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Lightbulb, Scale, TrendingUp, Wallet, Store, Handshake, Tag, UserPlus, Repeat } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const FRAMEWORK = [
|
||||
{ icon: TrendingUp, title: '预期收益', desc: '各方案的收益估算与时间线' },
|
||||
{ icon: Scale, title: '主要风险', desc: '每个方案的核心风险点' },
|
||||
{ icon: Wallet, title: '现金流影响', desc: '对短期和长期现金流的冲击' },
|
||||
{ icon: Lightbulb, title: '推荐程度', desc: '综合评估后的推荐等级' },
|
||||
]
|
||||
|
||||
/** 决策话题:每个话题对应不同的示例问题 */
|
||||
const TOPICS = [
|
||||
{
|
||||
icon: Store,
|
||||
title: '扩张收缩',
|
||||
desc: '开店、关店、业务线调整',
|
||||
samples: [
|
||||
'现在适合扩张开第二家店吗?',
|
||||
'要不要关闭亏损的业务线?',
|
||||
'新业务线该自营还是加盟?',
|
||||
'应该先扩张还是先巩固现有市场?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Handshake,
|
||||
title: '项目承接',
|
||||
desc: '大单、长期合同、付款条件',
|
||||
samples: [
|
||||
'是否应该接一个付款周期 90 天的大单?',
|
||||
'长期框架合同要不要签?',
|
||||
'预付款比例低的项目能接吗?',
|
||||
'客户要求独家合作,利弊怎么权衡?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Tag,
|
||||
title: '定价策略',
|
||||
desc: '降价、涨价、促销',
|
||||
samples: [
|
||||
'该不该降价 15% 抢竞品的客户?',
|
||||
'产品涨价 10% 会流失多少客户?',
|
||||
'要不要做满减促销活动?',
|
||||
'新产品定价应该走高端还是性价比?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: UserPlus,
|
||||
title: '招人裁员',
|
||||
desc: '关键岗位、团队扩张、减员',
|
||||
samples: [
|
||||
'应该招一个销售总监吗?',
|
||||
'要不要招应届生自己培养?',
|
||||
'团队人效低,该裁谁?',
|
||||
'核心员工被挖,要不要加薪留人?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Wallet,
|
||||
title: '融资合伙',
|
||||
desc: '投资、入股、融资',
|
||||
samples: [
|
||||
'要不要接受天使投资?',
|
||||
'合伙人要退股,怎么谈条件?',
|
||||
'朋友要入股,股权怎么分?',
|
||||
'银行贷款还是股权融资更合适?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Repeat,
|
||||
title: '转型外包',
|
||||
desc: '业务转型、外包、合作',
|
||||
samples: [
|
||||
'传统业务要不要转型线上?',
|
||||
'非核心业务外包还是自己做?',
|
||||
'要不要和同行做联合采购?',
|
||||
'代工还是自建生产线?',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 经营决策助手:情景推演、方案对比、收益/风险/现金流/推荐程度 */
|
||||
export function Decisions() {
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="经营决策助手"
|
||||
description="项目承接、扩张收缩、招人、降价、业务线调整、融资合伙。情景推演,把利弊讲透。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">决策推演框架</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FRAMEWORK.map((f) => (
|
||||
<div key={f.title} className="rounded-md border p-3">
|
||||
<f.icon className="mb-2 h-5 w-5 text-primary" aria-hidden />
|
||||
<p className="text-sm font-medium">{f.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">决策话题</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TOPICS.map((t, i) => (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="decision"
|
||||
samples={TOPICS[selectedTopic].samples}
|
||||
topic={TOPICS[selectedTopic].title}
|
||||
defaultMode="normal"
|
||||
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
/>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
AI 提供情景推演和利弊分析,最终决策由老板做出。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { Brain, HeartHandshake, Zap, Phone } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EmotionMode } from '@/types'
|
||||
|
||||
const MODES: { value: EmotionMode; label: string; desc: string; icon: typeof Brain; samples: string[] }[] = [
|
||||
{
|
||||
value: 'calm',
|
||||
label: '冷静分析',
|
||||
desc: '事实、风险、方案优先,克制客观',
|
||||
icon: Brain,
|
||||
samples: [
|
||||
'合伙人背叛了,我很愤怒,该怎么处理?',
|
||||
'明天的谈判我拖延了一周没准备',
|
||||
'员工集体离职,我该怎么应对?',
|
||||
'竞品抄袭我们,理性分析该怎么反击',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'companion',
|
||||
label: '陪伴疏导',
|
||||
desc: '先承接情绪,再给出下一步,温和不评判',
|
||||
icon: HeartHandshake,
|
||||
samples: [
|
||||
'最近压力很大,睡不着觉',
|
||||
'感觉撑不下去了',
|
||||
'家人不理解我创业的辛苦',
|
||||
'今天被客户骂了,很难受',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'push',
|
||||
label: '强力推动',
|
||||
desc: '把拖延拆成今天可完成的小动作,直接有力',
|
||||
icon: Zap,
|
||||
samples: [
|
||||
'一直拖延不想做年底复盘',
|
||||
'知道该运动但就是不动',
|
||||
'积压了一堆邮件不想处理',
|
||||
'该打的催款电话一直拖着不打',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 情绪价值引擎:三模式切换 + 危机干预入口 */
|
||||
export function EmotionCenter() {
|
||||
const [emotionMode, setEmotionMode] = useState<EmotionMode>('calm')
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="情绪价值引擎"
|
||||
description="先处理情绪,再处理事情。选择适合你当前状态的模式。"
|
||||
>
|
||||
{/* 危机干预提示 */}
|
||||
<Card className="border-rose-200 bg-rose-50">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<Phone className="h-5 w-5 shrink-0 text-rose-600" aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-rose-900">危机干预</p>
|
||||
<p className="text-xs text-rose-700">
|
||||
如果你正在经历强烈痛苦或有自伤念头,请立即拨打心理援助热线 400-161-9995 或前往最近医院急诊。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 情绪模式选择器 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">回复模式</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => setEmotionMode(m.value)}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-md border p-4 text-left transition-colors',
|
||||
emotionMode === m.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<m.icon className="mb-1 h-5 w-5 text-primary" aria-hidden />
|
||||
<span className="text-sm font-medium">{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{m.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="emotion"
|
||||
samples={MODES.find((m) => m.value === emotionMode)?.samples || []}
|
||||
topic={MODES.find((m) => m.value === emotionMode)?.label}
|
||||
defaultMode="ephemeral"
|
||||
emotionMode={emotionMode}
|
||||
placeholder="说说你现在的感受…"
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
/>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Users,
|
||||
Scale,
|
||||
TrendingUp,
|
||||
Heart,
|
||||
Coffee,
|
||||
Star,
|
||||
Check,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Markdown } from '@/components/common/Markdown'
|
||||
import { ExpertAvatar } from '@/components/common/ExpertAvatar'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Expert, ExpertService, Consultation } from '@/types'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'legal', label: '法务', icon: Scale },
|
||||
{ value: 'decision', label: '经营', icon: TrendingUp },
|
||||
{ value: 'emotion', label: '情绪', icon: Heart },
|
||||
{ value: 'life', label: '生活', icon: Coffee },
|
||||
]
|
||||
|
||||
/** 专家库:分类浏览专家,选定专家,查看服务和咨询 */
|
||||
export function Experts() {
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const [services, setServices] = useState<ExpertService[]>([])
|
||||
const [consultations, setConsultations] = useState<Consultation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterCategory, setFilterCategory] = useState<string>('all')
|
||||
const [selectingExpert, setSelectingExpert] = useState<Expert | null>(null)
|
||||
const [cancelService, setCancelService] = useState<ExpertService | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [tab, setTab] = useState<'experts' | 'services' | 'consultations'>('experts')
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [expertRes, serviceRes, consultRes] = await Promise.all([
|
||||
api.listExperts(filterCategory !== 'all' ? filterCategory : undefined),
|
||||
api.myServices(),
|
||||
api.myConsultations(),
|
||||
])
|
||||
setExperts(expertRes.results)
|
||||
setServices(serviceRes.results)
|
||||
setConsultations(consultRes.results)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [filterCategory])
|
||||
|
||||
const handleSelect = async (expert: Expert) => {
|
||||
setSelectingExpert(expert)
|
||||
try {
|
||||
await api.selectExpert(expert.id)
|
||||
toast.success(`已选定${expert.name},等待专家确认`)
|
||||
setSelectingExpert(null)
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '选定失败')
|
||||
setSelectingExpert(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认取消服务关系 */
|
||||
const handleCancelService = async () => {
|
||||
if (!cancelService) return
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.cancelMyService(cancelService.service_id)
|
||||
toast.success('服务关系已取消')
|
||||
setCancelService(null)
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '取消失败')
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const serviceStatusMap = new Map(services.map(s => [s.expert_id, s.status]))
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="专家库"
|
||||
description="分类浏览法务、经营、情绪、生活专家,选定后可一键将 AI 对话话题发给专家咨询。"
|
||||
>
|
||||
{/* Tab 切换 */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button variant={tab === 'experts' ? 'default' : 'outline'} size="sm" onClick={() => setTab('experts')}>
|
||||
<Users className="h-3.5 w-3.5" /> 专家列表
|
||||
</Button>
|
||||
<Button variant={tab === 'services' ? 'default' : 'outline'} size="sm" onClick={() => setTab('services')}>
|
||||
<Check className="h-3.5 w-3.5" /> 我的服务 ({services.length})
|
||||
</Button>
|
||||
<Button variant={tab === 'consultations' ? 'default' : 'outline'} size="sm" onClick={() => setTab('consultations')}>
|
||||
<MessageSquare className="h-3.5 w-3.5" /> 我的咨询 ({consultations.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tab === 'experts' && (
|
||||
<>
|
||||
{/* 分类筛选 */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button variant={filterCategory === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilterCategory('all')}>
|
||||
全部
|
||||
</Button>
|
||||
{CATEGORIES.map((c) => (
|
||||
<Button key={c.value} variant={filterCategory === c.value ? 'default' : 'outline'} size="sm" onClick={() => setFilterCategory(c.value)}>
|
||||
<c.icon className="h-3.5 w-3.5" /> {c.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? <LoadingState /> : error ? (
|
||||
<EmptyState title="加载失败" description={error} icon={<Users className="h-8 w-8" />} />
|
||||
) : experts.length === 0 ? (
|
||||
<EmptyState title="暂无专家" description="该分类下还没有上架的专家" icon={<Users className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{experts.map((expert) => {
|
||||
const catInfo = CATEGORIES.find(c => c.value === expert.category)
|
||||
const serviceStatus = serviceStatusMap.get(expert.id)
|
||||
const stars = (expert.rate / 100).toFixed(1)
|
||||
return (
|
||||
<Card key={expert.id}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<ExpertAvatar name={expert.name} avatarUrl={expert.avatar_url} size="xl" shape="rect" className="shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium truncate">{expert.name}</h3>
|
||||
{catInfo && <Badge variant="secondary">{catInfo.label}</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground truncate">{expert.title}</p>
|
||||
<div className="mt-1 flex items-center gap-1 text-xs">
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
<span>{stars}</span>
|
||||
<span className="text-muted-foreground">({expert.rating_count}人评价)</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">{expert.bio}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{expert.expertise.split(',').map((tag, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 border-t pt-3">
|
||||
{serviceStatus === 'active' ? (
|
||||
<Badge className="w-full justify-center"><Check className="h-3 w-3 mr-1" />服务中</Badge>
|
||||
) : serviceStatus === 'pending' ? (
|
||||
<Badge variant="outline" className="w-full justify-center">等待专家确认</Badge>
|
||||
) : (
|
||||
<Button size="sm" className="w-full" onClick={() => handleSelect(expert)} disabled={selectingExpert?.id === expert.id}>
|
||||
{selectingExpert?.id === expert.id && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
|
||||
选定专家
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'services' && (
|
||||
<>
|
||||
{loading ? <LoadingState /> : services.length === 0 ? (
|
||||
<EmptyState title="暂无服务" description="选定专家后,服务关系将显示在这里" icon={<Check className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{services.map((s) => {
|
||||
const catInfo = CATEGORIES.find(c => c.value === s.category)
|
||||
return (
|
||||
<Card key={s.service_id}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{s.expert_name}</h3>
|
||||
{catInfo && <Badge variant="secondary" className="shrink-0">{catInfo.label}</Badge>}
|
||||
<span className="truncate text-sm text-muted-foreground">{s.expert_title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{s.expert_bio}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">选定时间:{s.created_at}</p>
|
||||
</div>
|
||||
<Badge variant={s.status === 'active' ? 'default' : 'outline'} className="shrink-0">
|
||||
{s.status === 'active' ? '服务中' : s.status === 'pending' ? '待确认' : '已结束'}
|
||||
</Badge>
|
||||
</div>
|
||||
{(s.status === 'active' || s.status === 'pending') && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-rose-600 hover:bg-rose-50"
|
||||
onClick={() => setCancelService(s)}
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" /> 取消服务
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'consultations' && (
|
||||
<>
|
||||
{loading ? <LoadingState /> : consultations.length === 0 ? (
|
||||
<EmptyState title="暂无咨询" description="在与 AI 对话时,可一键将话题发给专家" icon={<MessageSquare className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{consultations.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{c.expert_name}</h3>
|
||||
<span className="truncate text-sm text-muted-foreground">{c.expert_title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium">{c.topic}</p>
|
||||
{c.question && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<Markdown content={c.question} />
|
||||
</div>
|
||||
)}
|
||||
{c.reply && (
|
||||
<div className="mt-2 rounded-md bg-muted/30 p-3 text-sm">
|
||||
<p className="font-medium text-xs text-muted-foreground mb-1">专家回复:</p>
|
||||
<Markdown content={c.reply} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">{c.created_at}</p>
|
||||
</div>
|
||||
<Badge variant={c.status === 'replied' ? 'default' : 'outline'} className="shrink-0">
|
||||
{c.status === 'replied' ? '已回复' : c.status === 'pending' ? '待回复' : '已关闭'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 取消服务确认弹窗 */}
|
||||
<Dialog open={!!cancelService} onOpenChange={(o) => !o && setCancelService(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>取消服务关系</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认取消与「{cancelService?.expert_name}」的服务关系?取消后不可恢复,如需再次服务请重新选定。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelService(null)}>再想想</Button>
|
||||
<Button variant="destructive" onClick={handleCancelService} disabled={cancelling}>
|
||||
{cancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
|
||||
确认取消
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
BookOpen,
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
FileText,
|
||||
Scale,
|
||||
FileEdit,
|
||||
MapPin,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Factory,
|
||||
Heart,
|
||||
Coffee,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { KnowledgeDoc } from '@/types'
|
||||
|
||||
const DOC_TYPES = [
|
||||
{ value: 'law', label: '法规', icon: Scale },
|
||||
{ value: 'case', label: '案例', icon: FileText },
|
||||
{ value: 'template', label: '模板', icon: FileEdit },
|
||||
{ value: 'guide', label: '经营指南', icon: TrendingUp },
|
||||
{ value: 'industry', label: '行业知识', icon: Factory },
|
||||
{ value: 'emotion', label: '情绪疏导', icon: Heart },
|
||||
{ value: 'life', label: '生活常识', icon: Coffee },
|
||||
]
|
||||
|
||||
/** 来源英文 → 中文映射 */
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
seed: '系统内置',
|
||||
manual: '手动录入',
|
||||
import: '导入',
|
||||
api: 'API 录入',
|
||||
}
|
||||
|
||||
/** 法规知识库管理:7 类分类,录入、检索、删除 */
|
||||
export function KnowledgeBase() {
|
||||
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<KnowledgeDoc[] | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgeDoc | null>(null)
|
||||
|
||||
// 新增表单
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
doc_type: 'law',
|
||||
region: '',
|
||||
effective_date: '',
|
||||
keywords: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadDocs = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.listKnowledge(filterType !== 'all' ? filterType : undefined)
|
||||
setDocs(res.results)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDocs()
|
||||
}, [filterType])
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await api.searchKnowledge(searchQuery.trim())
|
||||
setSearchResults(res.results)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '检索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.title.trim() || !form.content.trim()) {
|
||||
toast.error('标题和内容不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.addKnowledge({
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
doc_type: form.doc_type,
|
||||
region: form.region.trim() || null,
|
||||
effective_date: form.effective_date.trim() || null,
|
||||
keywords: form.keywords.trim() || null,
|
||||
})
|
||||
toast.success('文档已添加')
|
||||
setAddOpen(false)
|
||||
setForm({ title: '', content: '', doc_type: 'law', region: '', effective_date: '', keywords: '' })
|
||||
loadDocs()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '添加失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return
|
||||
try {
|
||||
await api.deleteKnowledge(deleteTarget.id)
|
||||
toast.success('文档已删除')
|
||||
setDeleteTarget(null)
|
||||
loadDocs()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const displayDocs = searchResults ?? docs
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="知识库"
|
||||
description="7 类知识库:法规、案例、模板、经营指南、行业知识、情绪疏导、生活常识。提问时按场景自动注入相关知识。"
|
||||
actions={
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-3.5 w-3.5" /> 新增文档
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-w-[95vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增知识文档</DialogTitle>
|
||||
<DialogDescription>录入后将进入 RAG 检索库,提问时按场景自动引用相关知识。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="kb-title">标题</Label>
|
||||
<Input id="kb-title" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:劳动合同法-试用期解除" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-content">内容</Label>
|
||||
<Textarea id="kb-content" value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} placeholder="法规条文、案例摘要或模板正文" className="min-h-[120px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="kb-type">类型</Label>
|
||||
<Select value={form.doc_type} onValueChange={(v) => setForm({ ...form, doc_type: v })}>
|
||||
<SelectTrigger id="kb-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOC_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-region">地区</Label>
|
||||
<Input id="kb-region" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="如:全国 / 上海" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="kb-date">生效日期</Label>
|
||||
<Input id="kb-date" type="date" value={form.effective_date} onChange={(e) => setForm({ ...form, effective_date: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-keywords">关键词</Label>
|
||||
<Input id="kb-keywords" value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="逗号分隔,如:试用期,解除" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAdd} disabled={saving}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />} 保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
>
|
||||
{/* 检索栏 */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="检索法规、案例、经营指南、行业知识…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={searching}>
|
||||
{searching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
|
||||
检索
|
||||
</Button>
|
||||
{searchResults && (
|
||||
<Button variant="outline" onClick={() => { setSearchResults(null); setSearchQuery('') }}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 类型筛选 */}
|
||||
{!searchResults && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filterType === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType('all')}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{DOC_TYPES.map((t) => (
|
||||
<Button
|
||||
key={t.value}
|
||||
variant={filterType === t.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType(t.value)}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" /> {t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 文档列表 */}
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadDocs} />
|
||||
) : displayDocs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={searchResults ? '未找到匹配文档' : '知识库为空'}
|
||||
description={searchResults ? '尝试更换关键词' : '点击右上角新增文档,录入法规、案例、经营指南等知识'}
|
||||
icon={<BookOpen className="h-8 w-8" aria-hidden />}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{searchResults && (
|
||||
<p className="text-sm text-muted-foreground">找到 {searchResults.length} 条匹配结果</p>
|
||||
)}
|
||||
{displayDocs.map((doc) => {
|
||||
const typeInfo = DOC_TYPES.find((t) => t.value === doc.doc_type)
|
||||
return (
|
||||
<Card key={doc.id} className={cn(searchResults && doc.score && 'border-l-4 border-l-primary')}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{typeInfo && <typeInfo.icon className="h-4 w-4 text-muted-foreground" aria-hidden />}
|
||||
<h3 className="font-medium">{doc.title}</h3>
|
||||
{doc.doc_type && (
|
||||
<Badge variant="secondary">{typeInfo?.label || doc.doc_type}</Badge>
|
||||
)}
|
||||
{searchResults && doc.score && (
|
||||
<Badge variant="outline">相关度 {doc.score}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" /> 来源:{SOURCE_LABELS[doc.source] || doc.source}
|
||||
</span>
|
||||
{doc.region && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" /> {doc.region}
|
||||
</span>
|
||||
)}
|
||||
{doc.effective_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" /> {doc.effective_date}
|
||||
</span>
|
||||
)}
|
||||
{doc.keywords && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" /> {doc.keywords}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!searchResults && doc.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-rose-600"
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除?</DialogTitle>
|
||||
<DialogDescription>将永久删除「{deleteTarget?.title}」,此操作不可恢复。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Briefcase, FileText, Coins, Users, Receipt, Gavel } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 法务话题:每个话题对应不同的示例问题 */
|
||||
const TOPICS = [
|
||||
{
|
||||
icon: Briefcase,
|
||||
title: '劳动用工',
|
||||
desc: '工伤、解雇、工资、社保',
|
||||
samples: [
|
||||
'员工工伤后能否开除?',
|
||||
'员工旷工三天怎么处理?',
|
||||
'试用期不合格怎么辞退才合法?',
|
||||
'加班费计算基数怎么确定?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: '合同审查',
|
||||
desc: '违约金、付款条款、风险点',
|
||||
samples: [
|
||||
'这份合同付款条款有什么风险?',
|
||||
'合同违约金怎么约定才合理?',
|
||||
'对方提供的格式合同有哪些坑?',
|
||||
'口头协议有法律效力吗?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Coins,
|
||||
title: '债权债务',
|
||||
desc: '催款、欠款、债务纠纷',
|
||||
samples: [
|
||||
'客户拖欠货款 3 个月如何催收?',
|
||||
'欠款人跑了,钱还能要回来吗?',
|
||||
'供应商要求先款后货,有风险吗?',
|
||||
'债务超过诉讼时效怎么办?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: '股东纠纷',
|
||||
desc: '股权、分红、退股',
|
||||
samples: [
|
||||
'合伙人要退股,怎么谈条件?',
|
||||
'小股东查账被拒绝怎么办?',
|
||||
'股东会决议不执行怎么处理?',
|
||||
'隐名股东权益如何保护?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Receipt,
|
||||
title: '税务风险',
|
||||
desc: '稽查、发票、合规',
|
||||
samples: [
|
||||
'收到税务稽查通知怎么办?',
|
||||
'公司缺进项发票怎么解决?',
|
||||
'个税汇算清缴有哪些风险点?',
|
||||
'关联交易定价被质疑怎么办?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Gavel,
|
||||
title: '诉讼仲裁',
|
||||
desc: '起诉、应诉、执行',
|
||||
samples: [
|
||||
'官司打赢了对方不执行怎么办?',
|
||||
'收到法院传票要不要请律师?',
|
||||
'仲裁和诉讼哪个更适合?',
|
||||
'诉讼时效快到了还能起诉吗?',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 法务参谋:劳动用工、合同审查、债权债务、股东纠纷、税务风险 */
|
||||
export function LegalAdvisor() {
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="法务参谋"
|
||||
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险。AI 输出不替代执业律师正式意见。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">法务话题</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TOPICS.map((t, i) => (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="legal"
|
||||
samples={TOPICS[selectedTopic].samples}
|
||||
topic={TOPICS[selectedTopic].title}
|
||||
defaultMode="confidential"
|
||||
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
|
||||
showRiskCard
|
||||
enableUpload
|
||||
/>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。
|
||||
重大风险事项请寻求执业专业人士复核。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Heart, Users, Plane, Moon, GraduationCap, Stethoscope } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 话题定义:每个话题对应不同的示例问题 */
|
||||
const TOPICS = [
|
||||
{
|
||||
icon: Users,
|
||||
title: '家庭沟通',
|
||||
desc: '伴侣、子女、父母关系',
|
||||
samples: [
|
||||
'怎么和青春期的孩子沟通不想他接班的事?',
|
||||
'和伴侣在花钱观念上有分歧,怎么谈?',
|
||||
'父母总是干涉经营决策,怎么温和地划清边界?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
title: '子女教育',
|
||||
desc: '升学、留学、职业方向',
|
||||
samples: [
|
||||
'孩子中考成绩一般,选普高还是国际学校?',
|
||||
'要不要送孩子出国留学?什么阶段出去最合适?',
|
||||
'孩子想学艺术,但担心就业,该怎么引导?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: '父母养老',
|
||||
desc: '照护方案、医疗安排',
|
||||
samples: [
|
||||
'父母年纪大了,居家养老还是去养老机构?',
|
||||
'父母需要长期服药,怎么管理用药和复诊?',
|
||||
'怎么和兄弟姐妹分担父母的养老责任?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Moon,
|
||||
title: '睡眠与压力',
|
||||
desc: '身心状态管理',
|
||||
samples: [
|
||||
'最近睡眠很差,有什么改善建议?',
|
||||
'经营压力大,经常焦虑,怎么自我调节?',
|
||||
'长期高强度工作,怎么避免职业倦怠?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Plane,
|
||||
title: '出差日程',
|
||||
desc: '行程、商务礼仪',
|
||||
samples: [
|
||||
'下周出差三天,怎么安排最高效?',
|
||||
'第一次见重要客户,商务礼仪要注意什么?',
|
||||
'出差期间公司有事,怎么远程处理不耽误?',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Stethoscope,
|
||||
title: '健康习惯',
|
||||
desc: '体检、运动、饮食',
|
||||
samples: [
|
||||
'体检报告有几项异常,需要进一步检查吗?',
|
||||
'久坐办公,每天多少运动量才够?',
|
||||
'应酬多,怎么调整饮食减少对身体的负担?',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 老板生活助理:降低非经营决策负担 */
|
||||
export function LifeAssistant() {
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="老板生活助理"
|
||||
description="家庭、健康、出差、重要事项。帮你处理经营之外的事,把精力留给关键决策。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">支持话题</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TOPICS.map((t, i) => (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="life"
|
||||
samples={TOPICS[selectedTopic].samples}
|
||||
topic={TOPICS[selectedTopic].title}
|
||||
defaultMode="confidential"
|
||||
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { setAuth, type Role } from '@/lib/auth'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, ShieldCheck, UserCog, Briefcase, Shield, User } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 角色配置 */
|
||||
const ROLES: Array<{
|
||||
value: Role
|
||||
label: string
|
||||
icon: typeof User
|
||||
desc: string
|
||||
home: string
|
||||
testAccount?: { phone: string; password: string; name: string }
|
||||
}> = [
|
||||
{
|
||||
value: 'user',
|
||||
label: '用户',
|
||||
icon: User,
|
||||
desc: '老板专属经营助手',
|
||||
home: '/',
|
||||
testAccount: { phone: '13800138000', password: '123456', name: '测试老板' },
|
||||
},
|
||||
{
|
||||
value: 'expert',
|
||||
label: '专家',
|
||||
icon: Briefcase,
|
||||
desc: '专家工作台,接单回复',
|
||||
home: '/workbench',
|
||||
testAccount: { phone: '13910000001', password: 'expert123', name: '李华律师' },
|
||||
},
|
||||
{
|
||||
value: 'admin',
|
||||
label: '管理员',
|
||||
icon: Shield,
|
||||
desc: '平台管理后台',
|
||||
home: '/admin',
|
||||
testAccount: { phone: '13800000000', password: 'admin123', name: '平台管理员' },
|
||||
},
|
||||
]
|
||||
|
||||
/** 登录/注册页:手机号+密码,支持用户/专家/管理员三种角色 */
|
||||
export function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [role, setRole] = useState<Role>('user')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const currentRole = ROLES.find((r) => r.value === role)!
|
||||
|
||||
/** 填充测试账号 */
|
||||
const fillTestAccount = () => {
|
||||
if (currentRole.testAccount) {
|
||||
setPhone(currentRole.testAccount.phone)
|
||||
setPassword(currentRole.testAccount.password)
|
||||
setName(currentRole.testAccount.name)
|
||||
setMode('login')
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换角色时清空表单 */
|
||||
const switchRole = (r: Role) => {
|
||||
setRole(r)
|
||||
setPhone('')
|
||||
setPassword('')
|
||||
setName('')
|
||||
setMode('login')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 前端校验
|
||||
if (!phone.trim()) {
|
||||
toast.error('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!password.trim()) {
|
||||
toast.error('请输入密码')
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
toast.error('手机号格式不正确,应为 11 位')
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error('密码至少 6 位')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
// 专家和管理员只支持登录,不支持注册
|
||||
if (role !== 'user' && mode === 'register') {
|
||||
toast.error('专家和管理员账号由平台统一创建,无需注册')
|
||||
setMode('login')
|
||||
return
|
||||
}
|
||||
const result = mode === 'login'
|
||||
? await api.login(phone, password)
|
||||
: await api.register(phone, password, name)
|
||||
// 登录返回 role,注册默认为 user
|
||||
const resultRole: Role = mode === 'login'
|
||||
? ((result as { role?: string }).role as Role) || 'user'
|
||||
: 'user'
|
||||
setAuth(result.token, result.phone, result.name, resultRole)
|
||||
toast.success(mode === 'login' ? '登录成功' : '注册成功')
|
||||
// 按角色跳转
|
||||
const targetRole = ROLES.find((r) => r.value === resultRole) || currentRole
|
||||
navigate(targetRole.home)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '操作失败'
|
||||
toast.error(msg, { duration: 4000 })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{mode === 'login' ? '登录 BossAi' : '注册 BossAi'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentRole.desc}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 角色 Tab */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg border p-2.5 text-sm transition',
|
||||
role === r.value
|
||||
? 'border-primary bg-primary/5 text-primary ring-1 ring-primary/20'
|
||||
: 'border-border text-muted-foreground hover:bg-muted/40',
|
||||
)}
|
||||
onClick={() => switchRole(r.value)}
|
||||
>
|
||||
<r.icon className="h-4 w-4" />
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
{mode === 'register' && role === 'user' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">昵称(可选)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="怎么称呼你?"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === 'login' ? '登录' : '注册'}
|
||||
</Button>
|
||||
|
||||
{/* 注册入口仅用户显示 */}
|
||||
{role === 'user' && (
|
||||
<div className="text-center text-sm">
|
||||
{mode === 'login' ? (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
没有账号?去注册
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
已有账号?去登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试账号快捷填充 */}
|
||||
{currentRole.testAccount && (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{currentRole.label}测试账号 {currentRole.testAccount.phone}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fillTestAccount} disabled={loading}>
|
||||
<UserCog className="mr-2 h-3.5 w-3.5" />
|
||||
一键填充
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Construction } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
|
||||
/** 占位页:尚未实现的模块 */
|
||||
export function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<PageLayout title={title} description="该模块将在后续迭代实现">
|
||||
<EmptyState
|
||||
title="建设中"
|
||||
description="此功能尚未开放,后续迭代上线。"
|
||||
icon={<Construction className="h-8 w-8" aria-hidden />}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Shield,
|
||||
Cloud,
|
||||
Database,
|
||||
Trash2,
|
||||
Bell,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
Loader2,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Settings } from '@/types'
|
||||
|
||||
/** 设置项配置 */
|
||||
const SETTING_GROUPS = [
|
||||
{
|
||||
title: '隐私与会话',
|
||||
icon: Shield,
|
||||
desc: '控制数据留存和销毁策略',
|
||||
items: [
|
||||
{ key: 'show_inference_location', label: '显示推理位置', desc: '在对话中标注当前处理位置(本地/边缘/云端),保持透明', icon: Eye, danger: false },
|
||||
{ key: 'ephemeral_disable_copy', label: '阅后即焚默认禁用复制', desc: '阅后即焚模式下禁止复制、导出、转发和长期记忆', icon: EyeOff, danger: false },
|
||||
{ key: 'auto_destroy_on_exit', label: '退出会话自动销毁', desc: '离开页面或关闭应用时自动销毁阅后即焚会话', icon: Trash2, danger: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据与记忆',
|
||||
icon: Database,
|
||||
desc: '控制 AI 记忆范围和数据用途',
|
||||
items: [
|
||||
{ key: 'long_term_memory', label: '长期记忆', desc: '记住你的压力来源、性格和决策风格,提供个性化建议。可随时关闭', icon: Database, danger: false },
|
||||
{ key: 'knowledge_base_write', label: '企业知识库写入', desc: '允许将脱敏内容写入企业知识库,供团队复用', icon: Database, danger: false },
|
||||
{ key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, BossAi 不采集训练数据', icon: Info, danger: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '云端授权',
|
||||
icon: Cloud,
|
||||
desc: '本地能力不足时,控制是否调用云端模型',
|
||||
items: [
|
||||
{ key: 'cloud_call_allowed', label: '允许云端调用', desc: '本地模型能力不足时,经授权后调用云端模型。关闭后仅使用本地/边缘推理', icon: Cloud, danger: false },
|
||||
{ key: 'pii_redact_on_transfer', label: 'PII 脱敏后传输', desc: '手机号、身份证、银行卡等敏感信息在传输前自动脱敏', icon: Shield, danger: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '通知与自动升级',
|
||||
icon: Bell,
|
||||
desc: '每日提醒、风险预警和自动升级规则',
|
||||
items: [
|
||||
{ key: 'daily_reminder', label: '每日经营提醒', desc: '每天早上推送今天最该做的一件事', icon: Bell, danger: false },
|
||||
{ key: 'risk_alert', label: '风险预警', desc: '检测到合同到期、应收逾期、员工试用期届满等风险时主动提醒', icon: Shield, danger: false },
|
||||
{ key: 'auto_escalate_red', label: '红色风险自动升级', desc: '检测到红色风险时自动创建复核工单,无需手动操作', icon: Shield, danger: false },
|
||||
{ key: 'auto_escalate_orange', label: '橙色风险自动升级', desc: '检测到橙色风险时自动创建复核工单(默认手动)', icon: Shield, danger: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 设置:从后端加载,修改后持久化到 PostgreSQL */
|
||||
export function Settings() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [pendingChanges, setPendingChanges] = useState<Settings>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.getSettings()
|
||||
setSettings(res.settings)
|
||||
setPendingChanges({})
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载设置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const toggle = (key: string) => {
|
||||
const current = settings?.[key] ?? 'false'
|
||||
const newValue = current === 'true' ? 'false' : 'true'
|
||||
setPendingChanges((prev) => ({ ...prev, [key]: newValue }))
|
||||
setSettings((prev) => prev ? { ...prev, [key]: newValue } : prev)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await api.updateSettings(pendingChanges)
|
||||
setSettings(res.settings)
|
||||
setPendingChanges({})
|
||||
setDirty(false)
|
||||
toast.success('设置已保存')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <PageLayout title="设置"><LoadingState /></PageLayout>
|
||||
if (error) return <PageLayout title="设置"><ErrorState message={error} onRetry={load} /></PageLayout>
|
||||
if (!settings) return null
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="设置"
|
||||
description="隐私偏好、数据留存、云端授权和通知设置。你对自己的数据拥有完全控制权。"
|
||||
actions={
|
||||
dirty ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load}>放弃</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{SETTING_GROUPS.map((group) => (
|
||||
<Card key={group.title}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<group.icon className="h-4 w-4" aria-hidden /> {group.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{group.desc}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{group.items.map((item) => {
|
||||
const on = settings[item.key] === 'true'
|
||||
const pending = pendingChanges[item.key]
|
||||
const isPending = pending !== undefined
|
||||
return (
|
||||
<div key={item.key} className="flex items-start gap-3 py-3">
|
||||
<item.icon className={cn('mt-0.5 h-5 w-5 shrink-0', item.danger ? 'text-rose-600' : 'text-primary')} aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{item.label}
|
||||
{isPending && <span className="ml-2 text-xs text-amber-600">未保存</span>}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(item.key)}
|
||||
className={cn(
|
||||
'relative h-6 w-11 shrink-0 rounded-full transition-colors',
|
||||
on ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
on ? 'translate-x-5' : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 危险操作 */}
|
||||
<Card className="border-rose-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base text-rose-700">
|
||||
<Trash2 className="h-4 w-4" aria-hidden /> 危险操作
|
||||
</CardTitle>
|
||||
<CardDescription>以下操作不可恢复,请谨慎执行</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">清除全部会话历史</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">销毁所有普通和保密模式的历史会话</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-rose-300 text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">导出并删除全部数据</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">导出你的全部数据后永久删除</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-rose-300 text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
|
||||
>
|
||||
导出并删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
设置已持久化到 PostgreSQL 数据库,跨会话保留。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Shield, Users, MessageSquare, Activity,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, CartesianGrid, LineChart, Line, Legend,
|
||||
} from 'recharts'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/lib/api'
|
||||
import { SCENE_LABELS, MODE_LABELS, RISK_LABELS, PIE_COLORS } from './constants'
|
||||
|
||||
type AiUsage = Awaited<ReturnType<typeof api.adminAiUsage>>
|
||||
|
||||
/** 管理后台 - AI 使用情况:提问统计、场景/模式/风险/推理位置分布、趋势、用户排行(不含具体内容) */
|
||||
export function AdminAiUsage() {
|
||||
const [data, setData] = useState<AiUsage | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminAiUsage()
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PageLayout title="AI 使用情况" description="用户 AI 使用统计(仅统计,不含具体对话内容)">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="AI 使用情况" description="用户 AI 使用统计(仅统计,不含具体对话内容)">
|
||||
<div className="space-y-6">
|
||||
{/* 概览数字 */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<MessageSquare className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.total_questions}</p>
|
||||
<p className="text-sm text-muted-foreground">总提问数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Users className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.active_users}</p>
|
||||
<p className="text-sm text-muted-foreground">活跃用户数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Activity className="mx-auto h-6 w-6 text-emerald-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.daily_trend.reduce((s, d) => s + d.count, 0)}</p>
|
||||
<p className="text-sm text-muted-foreground">近 7 天提问</p>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
{data.daily_trend.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">近 7 天提问趋势</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data.daily_trend.map(d => ({ date: d.date.slice(5), count: d.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="#3b82f6" strokeWidth={2} name="提问数" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* 场景分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按场景分布</p>
|
||||
{data.by_scene.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data.by_scene.map(s => ({ name: SCENE_LABELS[s.scene] || s.scene, value: s.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label>
|
||||
{data.by_scene.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<MessageSquare className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 隐私模式分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按隐私模式分布</p>
|
||||
{data.by_mode.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data.by_mode.map(m => ({ name: MODE_LABELS[m.mode] || m.mode, value: m.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label>
|
||||
{data.by_mode.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Shield className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 风险等级分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按风险等级分布</p>
|
||||
{data.by_risk.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data.by_risk.map(r => ({ name: RISK_LABELS[r.level] || r.level, count: r.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#f59e0b" name="回复数" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Shield className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 推理位置分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按推理位置分布</p>
|
||||
{data.by_inference.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data.by_inference.map(r => ({ name: r.location === 'cloud' ? '云端' : '本地', count: r.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#10b981" name="回复数" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Activity className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 用户使用排行 */}
|
||||
{data.by_user.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">用户使用情况(脱敏,Top 50)</p>
|
||||
<div className="space-y-2">
|
||||
{data.by_user.map((u, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{u.phone_masked}</span>
|
||||
{u.top_scene && (
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{SCENE_LABELS[u.top_scene] || u.top_scene}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>提问 {u.questions}</span>
|
||||
<span>回复 {u.replies}</span>
|
||||
<span>{u.last_active ? u.last_active.slice(0, 10) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Shield, Activity, Briefcase } from 'lucide-react'
|
||||
import {
|
||||
PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, CartesianGrid, LineChart, Line, Legend,
|
||||
} from 'recharts'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/lib/api'
|
||||
import { CATEGORY_LABELS, STATUS_LABELS, PIE_COLORS } from './constants'
|
||||
|
||||
type ExpertActivity = Awaited<ReturnType<typeof api.adminExpertActivity>>
|
||||
|
||||
/** 管理后台 - 专家沟通情况:咨询统计、状态/分类分布、专家统计、趋势、用户排行(不含具体内容) */
|
||||
export function AdminExpertActivity() {
|
||||
const [data, setData] = useState<ExpertActivity | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminExpertActivity()
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PageLayout title="专家沟通情况" description="用户与专家的咨询统计(仅统计,不含具体咨询内容)">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="专家沟通情况" description="用户与专家的咨询统计(仅统计,不含具体咨询内容)">
|
||||
<div className="space-y-6">
|
||||
{/* 概览数字 */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Activity className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.total}</p>
|
||||
<p className="text-sm text-muted-foreground">总咨询数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-emerald-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.replied}</p>
|
||||
<p className="text-sm text-muted-foreground">已回复</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-amber-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{data.pending}</p>
|
||||
<p className="text-sm text-muted-foreground">待回复</p>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
{data.daily_trend.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">近 7 天咨询趋势</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data.daily_trend.map(d => ({ date: d.date.slice(5), count: d.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="#10b981" strokeWidth={2} name="咨询数" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* 状态分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按状态分布</p>
|
||||
{data.by_status.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data.by_status.map(s => ({ name: STATUS_LABELS[s.status] || s.status, value: s.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label>
|
||||
{data.by_status.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Activity className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
|
||||
{/* 分类分布 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">按分类分布</p>
|
||||
{data.by_category.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data.by_category.map(c => ({ name: CATEGORY_LABELS[c.category] || c.category, count: c.count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#3b82f6" name="咨询数" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : <EmptyState title="暂无数据" icon={<Briefcase className="h-8 w-8" />} />}
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* 专家咨询统计 */}
|
||||
{data.by_expert.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">专家咨询统计</p>
|
||||
<div className="space-y-2">
|
||||
{data.by_expert.filter(e => e.total > 0).map((e, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{e.name}</span>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">{CATEGORY_LABELS[e.category] || e.category}</Badge>
|
||||
{e.rating_count > 0 && (
|
||||
<span className="text-xs text-amber-500">★ {e.avg_rating}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>总 {e.total}</span>
|
||||
<span className="text-emerald-500">已回 {e.replied}</span>
|
||||
<span className="text-amber-500">待回 {e.pending}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.by_expert.every(e => e.total === 0) && (
|
||||
<EmptyState title="暂无咨询记录" icon={<Activity className="h-8 w-8" />} />
|
||||
)}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
|
||||
{/* 用户咨询排行 */}
|
||||
{data.by_user.length > 0 && (
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">用户咨询情况(脱敏,Top 50)</p>
|
||||
<div className="space-y-2">
|
||||
{data.by_user.map((u, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{u.phone_masked}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>咨询 {u.consultations}</span>
|
||||
<span>{u.last_active ? u.last_active.slice(0, 10) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Briefcase, Loader2, Pencil, Power, PowerOff, Star, Trash2, Upload } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ExpertAvatar } from '@/components/common/ExpertAvatar'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Expert } from '@/types'
|
||||
import { CATEGORY_LABELS } from './constants'
|
||||
|
||||
/** 管理后台 - 专家管理:专家上下架、编辑信息、上传头像 */
|
||||
export function AdminExperts() {
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [toggling, setToggling] = useState<number | null>(null)
|
||||
const [editing, setEditing] = useState<Expert | null>(null)
|
||||
|
||||
const loadExperts = async () => {
|
||||
try {
|
||||
const res = await api.adminListExperts()
|
||||
setExperts(res.results)
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadExperts() }, [])
|
||||
|
||||
const handleToggle = async (expert: Expert) => {
|
||||
setToggling(expert.id)
|
||||
try {
|
||||
const newStatus = expert.status === 'active' ? 'inactive' : 'active'
|
||||
await api.adminUpdateExpertStatus(expert.id, newStatus)
|
||||
toast.success(newStatus === 'active' ? '已上架' : '已下架')
|
||||
loadExperts()
|
||||
} catch {
|
||||
toast.error('操作失败')
|
||||
} finally {
|
||||
setToggling(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title="专家管理" description="专家上下架管理">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="专家管理" description="专家上下架、编辑信息、上传头像">
|
||||
{experts.length === 0 ? (
|
||||
<EmptyState title="暂无专家" icon={<Briefcase className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{experts.map((ex) => {
|
||||
const stars = (ex.rate / 100).toFixed(1)
|
||||
return (
|
||||
<Card key={ex.id}>
|
||||
<CardContent className="p-5 pt-5">
|
||||
{/* 头部:长方形头像(左) + 姓名/状态(右) */}
|
||||
<div className="flex items-start gap-4">
|
||||
<ExpertAvatar name={ex.name} avatarUrl={ex.avatar_url} size="xl" shape="rect" className="shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium truncate">{ex.name}</h3>
|
||||
{ex.status === 'active'
|
||||
? <Badge>上架中</Badge>
|
||||
: <Badge variant="outline">已下架</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground truncate">{ex.title}</p>
|
||||
<div className="mt-1 flex items-center gap-1 text-xs">
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
<span>{stars}</span>
|
||||
<span className="text-muted-foreground">({ex.rating_count}人评价)</span>
|
||||
</div>
|
||||
{/* 简介 */}
|
||||
{ex.bio && (
|
||||
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">{ex.bio}</p>
|
||||
)}
|
||||
{/* 专长标签 */}
|
||||
{ex.expertise && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{ex.expertise.split(',').map((tag, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:分类 + 操作按钮 */}
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
|
||||
<Badge variant="secondary">{CATEGORY_LABELS[ex.category] || ex.category}</Badge>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(ex)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ex.status === 'active' ? 'destructive' : 'default'}
|
||||
onClick={() => handleToggle(ex)}
|
||||
disabled={toggling === ex.id}
|
||||
>
|
||||
{toggling === ex.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
|
||||
ex.status === 'active' ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
|
||||
{ex.status === 'active' ? '下架' : '上架'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editing && (
|
||||
<EditExpertDialog
|
||||
expert={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSuccess={() => { loadExperts(); setEditing(null) }}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑专家信息弹窗:含头像上传 + 表单字段 */
|
||||
function EditExpertDialog({
|
||||
expert,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
expert: Expert
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(expert.name)
|
||||
const [category, setCategory] = useState(expert.category)
|
||||
const [title, setTitle] = useState(expert.title)
|
||||
const [bio, setBio] = useState(expert.bio)
|
||||
const [expertise, setExpertise] = useState(expert.expertise)
|
||||
const [avatarUrl, setAvatarUrl] = useState(expert.avatar_url)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await api.adminUploadExpertAvatar(expert.id, file)
|
||||
setAvatarUrl(res.avatar_url)
|
||||
toast.success('头像上传成功')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
// 清空 input 允许重复选同一文件
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除头像:清空 avatarUrl,保存时传空字符串后端清除为 NULL */
|
||||
const handleRemoveAvatar = async () => {
|
||||
setAvatarUrl('')
|
||||
toast.success('头像已删除,保存后生效')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('姓名不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.adminUpdateExpert(expert.id, {
|
||||
name: name.trim(),
|
||||
category,
|
||||
title: title.trim(),
|
||||
bio: bio.trim(),
|
||||
expertise: expertise.trim(),
|
||||
avatar_url: avatarUrl,
|
||||
})
|
||||
toast.success('专家信息已更新')
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑专家信息</DialogTitle>
|
||||
<DialogDescription>修改专家资料、上传头像</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto py-2">
|
||||
{/* 头像上传区 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ExpertAvatar name={name || expert.name} avatarUrl={avatarUrl} size="2xl" shape="rect" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">专家头像</p>
|
||||
<p className="text-xs text-muted-foreground">支持 JPG / PNG / WebP,最大 5MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
{uploading ? '上传中…' : avatarUrl ? '更换图片' : '选择图片'}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-rose-600 hover:bg-rose-50"
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 姓名 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-name">姓名 *</Label>
|
||||
<Input id="edit-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="专家姓名" />
|
||||
</div>
|
||||
|
||||
{/* 分类 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>分类</Label>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v as Expert['category'])}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="legal">法务</SelectItem>
|
||||
<SelectItem value="decision">经营</SelectItem>
|
||||
<SelectItem value="emotion">情绪</SelectItem>
|
||||
<SelectItem value="life">生活</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 职称 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-title">职称 / 头衔</Label>
|
||||
<Input id="edit-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:高级律师" />
|
||||
</div>
|
||||
|
||||
{/* 简介 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-bio">简介</Label>
|
||||
<Textarea id="edit-bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="专家背景介绍" className="min-h-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* 专长 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-expertise">专长(逗号分隔)</Label>
|
||||
<Input id="edit-expertise" value={expertise} onChange={(e) => setExpertise(e.target.value)} placeholder="如:劳动用工,合同审查,股权纠纷" />
|
||||
<p className="text-xs text-muted-foreground">多个专长用英文逗号分隔</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Shield, Users, Briefcase, BarChart3 } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 管理后台 - 平台统计:注册用户、专家、服务关系、咨询概览 */
|
||||
export function AdminStats() {
|
||||
const [stats, setStats] = useState<Record<string, number> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminStats()
|
||||
.then(setStats)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title="平台统计" description="用户、专家、服务关系和咨询概览">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="平台统计" description="用户、专家、服务关系和咨询概览">
|
||||
{stats && (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Users className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.users}</p>
|
||||
<p className="text-sm text-muted-foreground">注册用户</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Briefcase className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.experts_active}/{stats.experts_total}</p>
|
||||
<p className="text-sm text-muted-foreground">上架/总专家</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<BarChart3 className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.services_active}</p>
|
||||
<p className="text-sm text-muted-foreground">服务中关系</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-primary" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.consultations_total}</p>
|
||||
<p className="text-sm text-muted-foreground">总咨询数</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="py-4 text-center">
|
||||
<Shield className="mx-auto h-6 w-6 text-amber-500" />
|
||||
<p className="mt-2 text-2xl font-bold">{stats.consultations_pending}</p>
|
||||
<p className="text-sm text-muted-foreground">待回复咨询</p>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Users } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 管理后台 - 用户管理:列出所有注册用户 */
|
||||
export function AdminUsers() {
|
||||
const [users, setUsers] = useState<Array<{ id: number; phone: string; name: string; created_at: string }>>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.adminListUsers()
|
||||
.then(res => setUsers(res.results))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title="用户管理" description="平台注册用户列表">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="用户管理" description="平台注册用户列表">
|
||||
{users.length === 0 ? (
|
||||
<EmptyState title="暂无用户" icon={<Users className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id}><CardContent className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{u.name || '未设置昵称'}</p>
|
||||
<p className="text-xs text-muted-foreground">{u.phone}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">注册:{u.created_at}</p>
|
||||
</CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 管理后台共享常量:标签映射、图表配色 */
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
legal: '法务', decision: '经营', emotion: '情绪', life: '生活',
|
||||
}
|
||||
|
||||
export const SCENE_LABELS: Record<string, string> = {
|
||||
legal: '法务参谋', decision: '经营决策', life: '生活助理', emotion: '情绪中心', unknown: '未知',
|
||||
}
|
||||
|
||||
export const MODE_LABELS: Record<string, string> = {
|
||||
normal: '普通模式', confidential: '保密模式',
|
||||
}
|
||||
|
||||
export const RISK_LABELS: Record<string, string> = {
|
||||
green: '低风险', amber: '需补证', orange: '建议复核', red: '高风险', unknown: '未知',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待回复', replied: '已回复', closed: '已关闭',
|
||||
}
|
||||
|
||||
/** 饼图配色 */
|
||||
export const PIE_COLORS = ['#3b82f6', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#6b7280']
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 管理后台页面集合 barrel 导出 */
|
||||
export { AdminStats } from './AdminStats'
|
||||
export { AdminAiUsage } from './AdminAiUsage'
|
||||
export { AdminExpertActivity } from './AdminExpertActivity'
|
||||
export { AdminUsers } from './AdminUsers'
|
||||
export { AdminExperts } from './AdminExperts'
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { MessageSquare, Send } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Markdown } from '@/components/common/Markdown'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Consultation } from '@/types'
|
||||
import { useWorkbenchData, WorkbenchShell, ReplyDialog, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 待回复咨询:用户发起的咨询,专家在此回复 */
|
||||
export function WorkbenchPending() {
|
||||
const { expert, consultations, loading, error, loadData } = useWorkbenchData()
|
||||
const [replyTarget, setReplyTarget] = useState<Consultation | null>(null)
|
||||
|
||||
const pendingConsultations = consultations.filter(c => c.status === 'pending')
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="待回复咨询" description="用户发起的咨询,在此回复">
|
||||
{pendingConsultations.length === 0 ? (
|
||||
<EmptyState title="暂无待回复咨询" description="用户发起咨询后会显示在这里" icon={<MessageSquare className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pendingConsultations.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{c.topic}</p>
|
||||
<Badge variant="destructive" className="shrink-0 text-xs">待回复</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">用户:{maskPhone(c.user_phone || '')}</p>
|
||||
{c.question && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<Markdown content={c.question} />
|
||||
</div>
|
||||
)}
|
||||
{c.context && (
|
||||
<div className="mt-2 rounded-md bg-muted/30 p-2 text-xs text-muted-foreground">
|
||||
<p className="mb-1 font-medium">AI 对话上下文:</p>
|
||||
<Markdown content={c.context} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">创建时间:{c.created_at}</p>
|
||||
</div>
|
||||
<Button size="sm" className="shrink-0" onClick={() => setReplyTarget(c)}>
|
||||
<Send className="h-3.5 w-3.5" /> 回复
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReplyDialog target={replyTarget} onClose={() => setReplyTarget(null)} onSuccess={loadData} />
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '@/lib/api'
|
||||
import { useWorkbenchData, WorkbenchShell, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 待确认服务:用户选定专家后,专家确认接单 */
|
||||
export function WorkbenchPendingServices() {
|
||||
const { expert, services, loading, error, loadData } = useWorkbenchData()
|
||||
const [accepting, setAccepting] = useState<number | null>(null)
|
||||
|
||||
const pendingServices = services.filter(s => s.status === 'pending')
|
||||
|
||||
const handleAccept = async (serviceId: number) => {
|
||||
setAccepting(serviceId)
|
||||
try {
|
||||
await api.acceptService(serviceId)
|
||||
toast.success('已确认服务关系')
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '确认失败')
|
||||
} finally {
|
||||
setAccepting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="待确认服务" description="用户选定你后,在此确认接单">
|
||||
{pendingServices.length === 0 ? (
|
||||
<EmptyState title="暂无待确认的服务" description="用户选定你后会显示在这里" icon={<Check className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pendingServices.map((s) => (
|
||||
<Card key={s.service_id}>
|
||||
<CardContent className="flex items-center justify-between gap-2 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">用户:{maskPhone(s.user_phone || '')}</p>
|
||||
<p className="text-xs text-muted-foreground">请求时间:{s.created_at}</p>
|
||||
</div>
|
||||
<Button size="sm" className="shrink-0" onClick={() => handleAccept(s.service_id)} disabled={accepting === s.service_id}>
|
||||
{accepting === s.service_id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
确认接单
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react'
|
||||
import { Star, Eye } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { Consultation } from '@/types'
|
||||
import { useWorkbenchData, WorkbenchShell, ConsultationDetailDialog, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 已回复咨询:查看已回复过的咨询详情 */
|
||||
export function WorkbenchReplied() {
|
||||
const { expert, consultations, loading, error } = useWorkbenchData()
|
||||
const [detailTarget, setDetailTarget] = useState<Consultation | null>(null)
|
||||
|
||||
const repliedConsultations = consultations.filter(c => c.status === 'replied')
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="已回复咨询" description="回复过的咨询,点击查看详情">
|
||||
{repliedConsultations.length === 0 ? (
|
||||
<EmptyState title="暂无已回复咨询" description="回复过的咨询会显示在这里" icon={<Star className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{repliedConsultations.map((c) => (
|
||||
<Card key={c.id} className="cursor-pointer transition hover:bg-muted/30" onClick={() => setDetailTarget(c)}>
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{c.topic}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">用户:{maskPhone(c.user_phone || '')}</p>
|
||||
</div>
|
||||
<Eye className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{c.reply}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">回复时间:{c.replied_at}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConsultationDetailDialog target={detailTarget} onClose={() => setDetailTarget(null)} />
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Briefcase, Loader2, XCircle } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { Consultation, ExpertService } from '@/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useWorkbenchData, WorkbenchShell, maskPhone } from './shared'
|
||||
|
||||
/** 专家工作台 - 服务中用户:已确认服务关系的用户列表,可取消服务 */
|
||||
export function WorkbenchUsers() {
|
||||
const { expert, services, consultations, loading, error, loadData } = useWorkbenchData()
|
||||
const [cancelService, setCancelService] = useState<ExpertService | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
const activeServices = services.filter(s => s.status === 'active')
|
||||
|
||||
/** 按服务用户分组咨询 */
|
||||
const consultationsByUser = useMemo(() => {
|
||||
const map = new Map<string, Consultation[]>()
|
||||
for (const c of consultations) {
|
||||
const phone = c.user_phone || 'unknown'
|
||||
if (!map.has(phone)) {
|
||||
map.set(phone, [])
|
||||
}
|
||||
map.get(phone)!.push(c)
|
||||
}
|
||||
return map
|
||||
}, [consultations])
|
||||
|
||||
/** 确认取消服务关系 */
|
||||
const handleCancel = async () => {
|
||||
if (!cancelService) return
|
||||
setCancelling(true)
|
||||
try {
|
||||
await api.cancelExpertService(cancelService.service_id)
|
||||
toast.success('服务关系已取消')
|
||||
setCancelService(null)
|
||||
loadData()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '取消失败')
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkbenchShell expert={expert} loading={loading} error={error} title="服务中用户" description="已确认服务关系的用户">
|
||||
{activeServices.length === 0 ? (
|
||||
<EmptyState title="暂无服务中用户" description="确认接单的用户会显示在这里" icon={<Briefcase className="h-8 w-8" />} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeServices.map((s) => {
|
||||
const userConsultations = consultationsByUser.get(s.user_phone || '') || []
|
||||
return (
|
||||
<Card key={s.service_id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">用户:{maskPhone(s.user_phone || '')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
签约时间:{s.signed_at}
|
||||
{userConsultations.length > 0 && ` · ${userConsultations.length} 条咨询`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="shrink-0">服务中</Badge>
|
||||
</div>
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-rose-600 hover:bg-rose-50"
|
||||
onClick={() => setCancelService(s)}
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" /> 取消服务
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 取消服务确认弹窗 */}
|
||||
<Dialog open={!!cancelService} onOpenChange={(o) => !o && setCancelService(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>取消服务关系</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认取消与用户「{cancelService ? maskPhone(cancelService.user_phone || '') : ''}」的服务关系?取消后不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelService(null)}>再想想</Button>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
|
||||
{cancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
|
||||
确认取消
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</WorkbenchShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 专家工作台页面集合 barrel 导出 */
|
||||
export { WorkbenchPendingServices } from './WorkbenchPendingServices'
|
||||
export { WorkbenchPending } from './WorkbenchPending'
|
||||
export { WorkbenchReplied } from './WorkbenchReplied'
|
||||
export { WorkbenchUsers } from './WorkbenchUsers'
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Briefcase, Loader2, Send, MessageSquare, FileText, Check } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Markdown } from '@/components/common/Markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Expert, ExpertService, Consultation } from '@/types'
|
||||
|
||||
/** 脱敏手机号 */
|
||||
export function maskPhone(phone: string): string {
|
||||
if (!phone || phone === 'unknown') return '未知用户'
|
||||
if (phone.length >= 8) return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
return phone
|
||||
}
|
||||
|
||||
/** 专家工作台数据加载 hook */
|
||||
export function useWorkbenchData() {
|
||||
const [expert, setExpert] = useState<Expert | null>(null)
|
||||
const [services, setServices] = useState<ExpertService[]>([])
|
||||
const [consultations, setConsultations] = useState<Consultation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.expertWorkbench()
|
||||
setExpert(res.expert)
|
||||
setServices(res.services)
|
||||
setConsultations(res.consultations)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
return { expert, services, consultations, loading, error, loadData }
|
||||
}
|
||||
|
||||
/** 回复弹窗 */
|
||||
export function ReplyDialog({
|
||||
target,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
target: Consultation | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
const handleReply = async () => {
|
||||
if (!target || !replyText.trim()) return
|
||||
setReplying(true)
|
||||
try {
|
||||
await api.replyConsultation(target.id, replyText.trim())
|
||||
toast.success('回复已发送')
|
||||
onClose()
|
||||
setReplyText('')
|
||||
onSuccess()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '回复失败')
|
||||
} finally {
|
||||
setReplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>回复咨询</DialogTitle>
|
||||
<DialogDescription>{target?.topic}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="输入你的专业回复…"
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleReply} disabled={replying || !replyText.trim()}>
|
||||
{replying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
发送回复
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** 咨询详情弹窗 */
|
||||
export function ConsultationDetailDialog({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: Consultation | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>咨询详情</DialogTitle>
|
||||
<DialogDescription>{target?.topic}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
|
||||
{/* 用户问题 */}
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<MessageSquare className="h-3.5 w-3.5" /> 用户问题
|
||||
</p>
|
||||
<div className="rounded-md bg-muted/30 p-3 text-sm">
|
||||
<Markdown content={target?.question || ''} />
|
||||
</div>
|
||||
</div>
|
||||
{/* AI 上下文 */}
|
||||
{target?.context && (
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" /> AI 对话上下文
|
||||
</p>
|
||||
<div className="rounded-md bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<Markdown content={target.context} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 专家回复 */}
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5 text-emerald-500" /> 专家回复
|
||||
</p>
|
||||
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-sm">
|
||||
<Markdown content={target?.reply || ''} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">回复时间:{target?.replied_at}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** 工作台页面外壳:处理 loading/error/非专家的通用逻辑 */
|
||||
export function WorkbenchShell({
|
||||
expert,
|
||||
loading,
|
||||
error,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
expert: Expert | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title={title} description={description}>
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout title={title} description={description}>
|
||||
<EmptyState title="无法访问" description={error} icon={<Briefcase className="h-8 w-8" />} />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
if (!expert) {
|
||||
return (
|
||||
<PageLayout title={title} description={description}>
|
||||
<EmptyState title="您不是注册专家" description="请联系管理员开通专家账号" icon={<Briefcase className="h-8 w-8" />} />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PageLayout title={title} description={description || `${expert.name} · ${expert.title}`}>
|
||||
{children}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/** 风险等级 */
|
||||
export type RiskLevel = 'green' | 'amber' | 'orange' | 'red'
|
||||
|
||||
/** 隐私会话模式 */
|
||||
export type SessionMode = 'normal' | 'confidential' | 'ephemeral'
|
||||
|
||||
/** 业务场景:对应后端 SCENE_PROMPTS */
|
||||
export type Scene = 'legal' | 'decision' | 'life' | 'emotion'
|
||||
|
||||
/** 情绪模式(仅 emotion 场景) */
|
||||
export type EmotionMode = 'calm' | 'companion' | 'push'
|
||||
|
||||
/** 风险卡片(后端 RiskCard 结构) */
|
||||
export interface RiskCard {
|
||||
conclusion: string
|
||||
level: RiskLevel
|
||||
actions: string[]
|
||||
evidence: string[]
|
||||
escalation: boolean
|
||||
}
|
||||
|
||||
/** 行动摘要(脱敏后可持久化) */
|
||||
export interface ActionSummary {
|
||||
risk_level: RiskLevel
|
||||
conclusion: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
/** /ask 响应 */
|
||||
export interface AskResponse {
|
||||
risk: RiskCard
|
||||
answer: string | null
|
||||
action_summary: ActionSummary
|
||||
model: 'kimi' | 'local-triage' | string
|
||||
scene: Scene
|
||||
rag_context?: KnowledgeDoc[]
|
||||
inference_location?: 'cloud' | 'local'
|
||||
fallback_used?: boolean
|
||||
}
|
||||
|
||||
/** /sessions 响应 */
|
||||
export interface SessionResponse {
|
||||
session_id: string
|
||||
mode: SessionMode
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
/** /sessions/{sid}/file 响应 */
|
||||
export interface FileInspectResponse {
|
||||
filename: string
|
||||
bytes: number
|
||||
text_preview: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 知识库检索结果 */
|
||||
export interface KnowledgeDoc {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
source: string
|
||||
doc_type?: string
|
||||
region?: string | null
|
||||
effective_date?: string | null
|
||||
status?: string
|
||||
keywords?: string | null
|
||||
score?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 专家 */
|
||||
export interface Expert {
|
||||
id: number
|
||||
name: string
|
||||
phone: string
|
||||
category: 'legal' | 'decision' | 'emotion' | 'life'
|
||||
title: string
|
||||
bio: string
|
||||
expertise: string
|
||||
avatar_url: string | null
|
||||
rate: number // 0-500,除以100为星级
|
||||
rating_count: number
|
||||
status: 'active' | 'inactive'
|
||||
created_at?: string
|
||||
ratings?: ExpertRating[]
|
||||
}
|
||||
|
||||
/** 专家评价 */
|
||||
export interface ExpertRating {
|
||||
id: number
|
||||
consultation_id: number
|
||||
user_phone: string // 脱敏
|
||||
rating: number
|
||||
comment: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 专家服务关系 */
|
||||
export interface ExpertService {
|
||||
service_id: number
|
||||
expert_id: number
|
||||
category: string
|
||||
status: 'pending' | 'active' | 'closed'
|
||||
signed_at: string | null
|
||||
created_at: string
|
||||
expert_name?: string
|
||||
expert_title?: string
|
||||
expert_bio?: string
|
||||
user_phone?: string // 工作台用
|
||||
}
|
||||
|
||||
/** 专家咨询 */
|
||||
export interface Consultation {
|
||||
id: number
|
||||
service_id: number
|
||||
expert_id: number
|
||||
category: string
|
||||
topic: string
|
||||
context?: string
|
||||
question?: string
|
||||
reply: string | null
|
||||
status: 'pending' | 'replied' | 'closed'
|
||||
replied_at: string | null
|
||||
created_at: string
|
||||
expert_name?: string
|
||||
expert_title?: string
|
||||
user_phone?: string
|
||||
}
|
||||
|
||||
/** 用户设置(key-value) */
|
||||
export type Settings = Record<string, string>
|
||||
|
||||
/** 对话历史会话摘要 */
|
||||
export interface HistorySession {
|
||||
session_id: string
|
||||
mode: SessionMode
|
||||
message_count: number
|
||||
first_message_at: string | null
|
||||
last_message_at: string | null
|
||||
scene: string | null
|
||||
}
|
||||
|
||||
/** 对话历史消息(含风险卡和元数据) */
|
||||
export interface HistoryMessage {
|
||||
id: number
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
mode: SessionMode
|
||||
risk: {
|
||||
level?: RiskLevel
|
||||
conclusion?: string
|
||||
actions?: string[]
|
||||
evidence?: string[]
|
||||
escalation?: boolean
|
||||
}
|
||||
meta: {
|
||||
scene?: Scene
|
||||
inference_location?: string
|
||||
model?: string
|
||||
fallback_used?: boolean
|
||||
}
|
||||
created_at: string | null
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user