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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-29 23:57:26 +08:00
co-authored by Devin
commit 5c21e8fcd0
101 changed files with 25273 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="BossAi - 隐私优先的企业法律与经营智能助手" />
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="BossAi" />
<title>BossAi · 老板经营助理</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
// 注册 Service Worker(PWA 离线支持)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// SW 注册失败不影响正常使用
})
})
}
</script>
</body>
</html>
+5533
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "bossai-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-scroll-area": "1.2.2",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slot": "1.1.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"highlight.js": "^11.12.0",
"lucide-react": "0.469.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-markdown": "^9.1.0",
"react-router-dom": "6.28.1",
"recharts": "2.15.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"sonner": "1.7.1",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.20",
"@types/node": "22.10.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"autoprefixer": "10.4.20",
"postcss": "8.4.49",
"tailwindcss": "3.4.17",
"typescript": "5.7.2",
"vite": "5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

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