feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 新增 web2/ 前端:任务卡看板、风险雷达、决策日志、我的团队、快捷指令
- ChatPanel 增强:风险卡置顶、案卡接入、起诉状向导、语音输入
- 事项看板:点击查看详情弹窗、断点续接(恢复历史对话+matterId)
- 保密模式恢复:密码验证后重建内存 session,保留多轮上下文
- 多轮对话:ask/ask-stream/ask-analyze 注入 session 历史,AI 有上下文
- 追问优化:紧扣用户问题,不再偏离主题(社保问社保,工伤问工伤)
- 对话区可滚动,输入区固定可见
- 状态机放宽:允许从任意非终态转到 pending_expert
- session 存活检查端点,后端重启后自动重建 session
- DB migration:风险推送表、案卡表

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-31 08:38:33 +08:00
co-authored by Devin
parent 7a788bf8dc
commit 4de36547cd
88 changed files with 15237 additions and 62 deletions
+57
View File
@@ -0,0 +1,57 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { AppLayout } from '@/components/layout/AppLayout'
import { Dashboard } from '@/pages/Dashboard'
import { Matters } from '@/pages/Matters'
import { LegalAdvisor } from '@/pages/LegalAdvisor'
import { Decisions } from '@/pages/Decisions'
import { LifeAssistant } from '@/pages/LifeAssistant'
import { EmotionCenter } from '@/pages/EmotionCenter'
import { Team } from '@/pages/Team'
import { DecisionsLog } from '@/pages/DecisionsLog'
import { KnowledgeBase } from '@/pages/KnowledgeBase'
import { ChatHistory } from '@/pages/ChatHistory'
import { MyProfile } from '@/pages/MyProfile'
import { Me } from '@/pages/Me'
import { Settings } from '@/pages/Settings'
import { Login } from '@/pages/Login'
import { Placeholder } from '@/pages/Placeholder'
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 />}>
{/* 用户端 2.0 */}
<Route index element={<ProtectedRoute allowedRoles={['user']}><Dashboard /></ProtectedRoute>} />
<Route path="matters" element={<ProtectedRoute allowedRoles={['user']}><Matters /></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="team" element={<ProtectedRoute allowedRoles={['user']}><Team /></ProtectedRoute>} />
<Route path="decisions-log" element={<ProtectedRoute allowedRoles={['user']}><DecisionsLog /></ProtectedRoute>} />
<Route path="knowledge" element={<ProtectedRoute allowedRoles={['user']}><KnowledgeBase /></ProtectedRoute>} />
<Route path="history" element={<ProtectedRoute allowedRoles={['user']}><ChatHistory /></ProtectedRoute>} />
<Route path="profile" element={<ProtectedRoute allowedRoles={['user']}><MyProfile /></ProtectedRoute>} />
<Route path="me" element={<ProtectedRoute allowedRoles={['user']}><Me /></ProtectedRoute>} />
<Route path="settings" element={<ProtectedRoute allowedRoles={['user']}><Settings /></ProtectedRoute>} />
{/* 专家/管理端占位(后续阶段实现) */}
<Route path="workbench/*" element={<Placeholder title="专家工作台(待迁移)" />} />
<Route path="admin/*" element={<Placeholder title="管理后台(待迁移)" />} />
<Route path="*" element={<Placeholder title="页面未找到" />} />
</Route>
</Routes>
)
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
/** 空状态组件 */
import { cn } from '@/lib/utils'
import type { ComponentType } from 'react'
interface EmptyStateProps {
title: string
description?: string
icon?: ComponentType<{ className?: string }>
action?: React.ReactNode
className?: string
}
export function EmptyState({ title, description, icon: Icon, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{Icon && (
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
)}
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{description && <p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
/** 错误状态组件(带重试) */
import { AlertCircle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ErrorStateProps {
message: string
onRetry?: () => void
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="mb-3 h-10 w-10 text-destructive" />
<p className="text-sm text-foreground">{message}</p>
{onRetry && (
<Button variant="outline" size="sm" className="mt-4" onClick={onRetry}>
<RefreshCw className="mr-2 h-4 w-4" /> 重试
</Button>
)}
</div>
)
}
@@ -0,0 +1,34 @@
/** 专家头像组件(带 fallback 首字母) */
import { cn } from '@/lib/utils'
interface ExpertAvatarProps {
name: string
avatarUrl?: string | null
size?: number
className?: string
}
export function ExpertAvatar({ name, avatarUrl, size = 40, className }: ExpertAvatarProps) {
const initial = name?.[0] || '?'
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={name}
style={{ width: size, height: size }}
className={cn('rounded-full object-cover', className)}
/>
)
}
return (
<div
style={{ width: size, height: size, fontSize: size * 0.4 }}
className={cn(
'flex items-center justify-center rounded-full bg-primary/10 text-primary font-medium',
className,
)}
>
{initial}
</div>
)
}
@@ -0,0 +1,12 @@
/** 加载状态组件 */
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
export function LoadingState({ label = '加载中...', className }: { label?: string; className?: string }) {
return (
<div className={cn('flex items-center justify-center py-12', className)}>
<Loader2 className="mr-2 h-5 w-5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">{label}</span>
</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>
)
}
+60
View File
@@ -0,0 +1,60 @@
/** 案卡组件(TASK-009)
* 公共组件:展示单张案卡,支持点击和归档
*/
import { Scale, TrendingUp, Heart, ShieldCheck, Archive } from 'lucide-react'
import { cn, MATTER_STATUS_LABELS, MATTER_STATUS_COLORS, SCENE_LABELS, formatRelative } from '@/lib/utils'
import type { Matter, Scene } from '@/types'
const SCENE_ICONS: Record<Scene, typeof Scale> = {
legal: Scale,
decision: TrendingUp,
life: Heart,
emotion: ShieldCheck,
}
interface MatterCardProps {
matter: Matter
onClick?: () => void
onArchive?: () => void
}
export function MatterCard({ matter, onClick, onArchive }: MatterCardProps) {
const Icon = SCENE_ICONS[matter.scene] || Scale
return (
<div
onClick={onClick}
className={cn(
'group cursor-pointer rounded-xl border bg-card p-3 shadow-sm transition-all hover:shadow-md',
MATTER_STATUS_COLORS[matter.status],
)}
>
<div className="flex items-start gap-2">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/60">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{matter.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{SCENE_LABELS[matter.scene]}</span>
<span>·</span>
<span>{formatRelative(matter.updated_at)}</span>
</div>
<div className="mt-2">
<span className={cn('inline-block rounded-full border px-2 py-0.5 text-xs', MATTER_STATUS_COLORS[matter.status])}>
{MATTER_STATUS_LABELS[matter.status]}
</span>
</div>
</div>
{onArchive && matter.status === 'closed' && (
<button
onClick={(e) => { e.stopPropagation(); onArchive() }}
className="opacity-0 transition-opacity group-hover:opacity-100"
aria-label="归档"
>
<Archive className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
</div>
)
}
@@ -0,0 +1,16 @@
/** 离线提示条 */
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(() => network.onChange(setOnline), [])
if (online) return null
return (
<div className="flex items-center justify-center gap-2 bg-amber-500 px-4 py-2 text-sm text-white">
<WifiOff className="h-4 w-4" />
网络已断开,部分功能不可用
</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 items-start justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-foreground md:text-2xl">{title}</h2>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
{children}
</div>
)
}
@@ -0,0 +1,201 @@
/** 快捷指令面板(TASK-014 + TASK-015)
* FAB 点击弹出底部抽屉,画像驱动的指令网格 + 语音/文字输入
*/
import { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Scale, TrendingUp, Heart, Mic, X, Send, Sparkles } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import type { QuickCommands, Scene } from '@/types'
interface QuickCommandPanelProps {
open: boolean
onClose: () => void
}
const SCENE_ICONS: Record<string, typeof Scale> = {
legal: Scale,
decision: TrendingUp,
life: Heart,
}
const SCENE_ROUTES: Record<string, string> = {
legal: '/legal',
decision: '/decisions',
life: '/life',
}
export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
const navigate = useNavigate()
const [commands, setCommands] = useState<QuickCommands | null>(null)
const [loading, setLoading] = useState(false)
const [textInput, setTextInput] = useState('')
const [listening, setListening] = useState(false)
const recognitionRef = useRef<any>(null)
/** 加载快捷指令 */
useEffect(() => {
if (open && !commands) {
setLoading(true)
api.getQuickCommands()
.then((c) => { setCommands(c); setLoading(false) })
.catch(() => setLoading(false))
}
}, [open, commands])
/** 点击指令:预填模板跳转对应页面 */
const handleCommandClick = (scene: string, template: string) => {
navigate(`${SCENE_ROUTES[scene] || '/legal'}?q=${encodeURIComponent(template)}`)
onClose()
}
/** 直接文字输入 */
const handleTextSubmit = () => {
if (!textInput.trim()) return
navigate(`/legal?q=${encodeURIComponent(textInput.trim())}`)
onClose()
setTextInput('')
}
/** 语音输入(Web Speech API) */
const startListening = () => {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
// 不支持语音识别,静默降级
return
}
if (listening) {
recognitionRef.current?.stop()
setListening(false)
return
}
const recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.continuous = false
recognition.interimResults = true
recognition.onresult = (event: any) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
setTextInput(transcript)
}
recognition.onerror = () => setListening(false)
recognition.onend = () => setListening(false)
recognition.start()
recognitionRef.current = recognition
setListening(true)
}
if (!open) return null
const sections: Array<{ key: Scene; label: string }> = [
{ key: 'legal', label: '法务' },
{ key: 'decision', label: '经营' },
{ key: 'life', label: '生活' },
]
return (
<>
{/* 遮罩 */}
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={onClose}
/>
{/* 底部抽屉 */}
<div className={cn(
'fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-card shadow-xl md:hidden',
'animate-slide-up',
)}>
{/* 顶部把手 + 关闭 */}
<div className="flex items-center justify-between border-b border-border p-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<span className="font-medium text-foreground">你需要什么帮助?</span>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4">
{/* 画像不完整提示 */}
{commands?.profile_incomplete && commands.incomplete_hint && (
<div className="mb-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
{commands.incomplete_hint}
</div>
)}
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">加载中...</div>
) : (
<>
{/* 指令分组 */}
{sections.map(({ key, label }) => {
const items = (commands?.[key as keyof QuickCommands] as Array<{ id: string; label: string; template: string }>) || []
if (items.length === 0) return null
const Icon = SCENE_ICONS[key] || Scale
return (
<div key={key} className="mb-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="h-4 w-4" />
{label}
</div>
<div className="grid grid-cols-3 gap-2">
{items.map((cmd) => (
<button
key={cmd.id}
onClick={() => handleCommandClick(key, cmd.template)}
className="rounded-xl border border-border bg-card p-3 text-center text-xs text-foreground transition hover:border-primary hover:bg-primary/5"
>
{cmd.label}
</button>
))}
</div>
</div>
)
})}
</>
)}
{/* 文字/语音输入 */}
<div className="mt-4 border-t border-border pt-4">
<div className="flex items-center gap-2">
<button
onClick={startListening}
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition',
listening
? 'bg-red-500 text-white animate-pulse'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
aria-label="语音输入"
>
<Mic className="h-5 w-5" />
</button>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleTextSubmit()}
placeholder={listening ? '正在聆听...' : '或直接输入你的问题...'}
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={handleTextSubmit}
disabled={!textInput.trim()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground disabled:opacity-50"
aria-label="发送"
>
<Send className="h-4 w-4" />
</button>
</div>
{listening && (
<p className="mt-2 text-center text-xs text-muted-foreground">点击麦克风停止</p>
)}
</div>
</div>
</div>
</>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { AlertOctagon, FileText, ListChecks, Users, Cloud, Cpu, AlertTriangle, BookOpen, Sparkles } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { StatusBadge } from './StatusBadge'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Markdown } from './Markdown'
import { cn } from '@/lib/utils'
import type { RiskCard, KnowledgeDoc } from '@/types'
/** 知识库文档类型标签映射 */
const DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
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
/** 知识库引用来源 */
ragContext?: KnowledgeDoc[]
/** 流式输出中(显示光标) */
streaming?: boolean
onEscalate?: () => void
}
/** 风险卡片:等级 + 结论 + 行动 + 证据 + 升级提示 + 模型答复 + 推理位置 + 参考来源 */
export function RiskCardView({ card, answer, model, inferenceLocation, fallbackUsed, ragContext, 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">
{/* 标题行:模型分析 + 参考来源 tag */}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-semibold text-muted-foreground">模型分析</h4>
{/* 参考来源 tag:有知识库引用时展示文档 tag,无引用时标注 AI 提供 */}
{!streaming && (
<div className="flex flex-wrap items-center gap-1.5">
{ragContext && ragContext.length > 0 ? (
<>
<BookOpen className="h-3 w-3 text-muted-foreground" aria-hidden />
{ragContext.map((doc, i) => (
<span key={i} className="flex items-center gap-1 text-[10px]">
<span className="font-medium text-foreground">{doc.title}</span>
{doc.doc_type && (
<Badge variant="secondary" className="text-[10px]">
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</Badge>
)}
{doc.region && (
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
)}
</span>
))}
</>
) : (
<>
<Sparkles className="h-3 w-3 text-muted-foreground" aria-hidden />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</>
)}
</div>
)}
</div>
<Markdown content={answer} streaming={streaming} className="text-sm" />
{/* 问专家按钮:每次对话都显示 */}
{!streaming && onEscalate && (
<div className="mt-3 border-t pt-2">
<Button size="sm" variant="outline" onClick={onEscalate}>
<Users className="h-3.5 w-3.5" /> 问专家
</Button>
</div>
)}
</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>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,22 @@
import { AlertTriangle, ShieldAlert, ShieldCheck, CircleAlert } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { RiskLevel } from '@/types'
const MAP: Record<RiskLevel, { label: string; cls: string; Icon: typeof ShieldCheck }> = {
green: { label: '绿色', cls: 'bg-emerald-100 text-emerald-700 border-emerald-200', Icon: ShieldCheck },
amber: { label: '黄色', cls: 'bg-amber-100 text-amber-700 border-amber-200', Icon: CircleAlert },
orange: { label: '橙色', cls: 'bg-orange-100 text-orange-700 border-orange-200', Icon: AlertTriangle },
red: { label: '红色', cls: 'bg-rose-100 text-rose-700 border-rose-200', Icon: ShieldAlert },
}
/** 风险等级徽章:统一呈现 green/amber/orange/red */
export function StatusBadge({ level, className }: { level: RiskLevel; className?: string }) {
const { label, cls, Icon } = MAP[level]
return (
<Badge variant="outline" className={cn(cls, 'gap-1', className)}>
<Icon className="h-3.5 w-3.5" aria-hidden />
{label}风险
</Badge>
)
}
+30
View File
@@ -0,0 +1,30 @@
/** BossAi 2.0 应用布局
* 桌面:左侧 Sidebar + 顶部 Header + 内容
* 移动:顶部 Header(精简) + 内容 + 底部 Tab + FAB
*/
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import { BottomTab } from './BottomTab'
import { Fab } from './Fab'
import { OfflineIndicator } from '@/components/common/OfflineIndicator'
import { Toaster } from 'sonner'
export function AppLayout() {
return (
<div className="min-h-screen bg-[#f8f9fb]">
<OfflineIndicator />
<Sidebar />
<div className="md:pl-60">
<Header />
<main className="container mx-auto max-w-7xl px-4 py-6">
<Outlet />
</main>
</div>
{/* 移动端底部 Tab + FAB */}
<BottomTab />
<Fab />
<Toaster position="top-center" richColors />
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
/** 移动端底部 Tab(仅 md 以下显示)
* 消费共享 NavConfig 的 MOBILE_TABS
*/
import { NavLink } from 'react-router-dom'
import { MOBILE_TABS } from '@/lib/nav-config'
import { cn } from '@/lib/utils'
export function BottomTab() {
return (
<nav className="bottom-tab fixed bottom-0 left-0 right-0 z-30 flex h-16 items-center justify-around border-t border-border bg-white/95 backdrop-blur md:hidden">
{MOBILE_TABS.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex flex-1 flex-col items-center gap-0.5 py-1 text-xs transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground',
)
}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
)
}
+22
View File
@@ -0,0 +1,22 @@
/** 浮动新建按钮(FAB)
* 移动端全局浮动,点击弹出快捷指令面板
*/
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { QuickCommandPanel } from '@/components/common/QuickCommandPanel'
export function Fab() {
const [open, setOpen] = useState(false)
return (
<>
<button
onClick={() => setOpen(true)}
className="fab fixed bottom-20 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 md:hidden"
aria-label="新建咨询"
>
<Plus className="h-6 w-6" />
</button>
<QuickCommandPanel open={open} onClose={() => setOpen(false)} />
</>
)
}
+41
View File
@@ -0,0 +1,41 @@
/** 顶部 Header(h-14 sticky)
* 桌面:显示当前页面标题 + 用户信息
* 移动:显示 Logo + 通知铃铛
*/
import { useLocation } from 'react-router-dom'
import { Bell } from 'lucide-react'
import { HEADER_TITLES } from '@/lib/nav-config'
import { getName } from '@/lib/auth'
export function Header() {
const { pathname } = useLocation()
const name = getName()
// 匹配标题(支持子路由,取最长匹配)
const title = Object.entries(HEADER_TITLES)
.filter(([p]) => pathname === p || (p !== '/' && pathname.startsWith(p)))
.sort((a, b) => b[0].length - a[0].length)[0]?.[1] || '左右 BossAi'
return (
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-white/95 px-4 backdrop-blur md:px-6">
{/* 移动端 Logo */}
<div className="flex items-center gap-2 md:hidden">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-xs">
左
</div>
</div>
<h1 className="flex-1 truncate text-base font-semibold text-foreground md:text-lg">{title}</h1>
{/* 通知铃铛 */}
<button
className="relative flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="通知"
>
<Bell className="h-5 w-5" />
{/* 角标占位(阶段三接入未读数) */}
</button>
{/* 用户名(桌面) */}
{name && (
<span className="hidden text-sm text-muted-foreground md:inline">{name}</span>
)}
</header>
)
}
+52
View File
@@ -0,0 +1,52 @@
/** 桌面端左侧导航(仅 md 以上显示)
* 消费共享 NavConfig,不再维护独立常量
*/
import { NavLink } from 'react-router-dom'
import { getNavByRole, groupNav } from '@/lib/nav-config'
import { getRole } from '@/lib/auth'
import { cn } from '@/lib/utils'
export function Sidebar() {
const role = getRole()
const nav = getNavByRole(role)
const groups = groupNav(nav)
return (
<aside className="hidden md:flex fixed left-0 top-0 z-30 h-screen w-60 flex-col border-r border-border bg-white">
{/* Logo */}
<div className="flex h-14 items-center gap-2 border-b border-border px-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
左
</div>
<span className="font-semibold text-foreground">左右 BossAi</span>
<span className="ml-auto text-xs text-muted-foreground">2.0</span>
</div>
{/* 导航 */}
<nav className="flex-1 overflow-y-auto py-4">
{groups.map(({ group, items }) => (
<div key={group} className="mb-4">
<div className="px-5 py-1 text-xs font-medium text-muted-foreground">{group}</div>
{items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-5 py-2 text-sm transition-colors',
isActive
? 'bg-primary/10 text-primary font-medium border-r-2 border-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)
}
>
<item.icon className="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</NavLink>
))}
</div>
))}
</nav>
</aside>
)
}
+29
View File
@@ -0,0 +1,29 @@
/** shadcn/ui 风格 Badge */
import { forwardRef } 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-medium transition-colors',
{
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',
},
},
defaultVariants: { variant: 'default' },
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
),
)
Badge.displayName = 'Badge'
+41
View File
@@ -0,0 +1,41 @@
/** shadcn/ui 风格 Button(复制自 v1.0,主色随 2.0 indigo) */
import { forwardRef } 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-nowrap rounded-lg text-sm font-medium ring-offset-background 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',
{
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-border bg-background hover:bg-muted hover:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-muted hover:text-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-lg px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export const Button = 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'
+47
View File
@@ -0,0 +1,47 @@
/** shadcn/ui 风格 Card */
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
const Card = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('card rounded-xl border border-border bg-card text-card-foreground shadow-sm', className)} {...props} />
),
)
Card.displayName = 'Card'
const CardHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
const CardDescription = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
)
CardDescription.displayName = 'CardDescription'
const CardContent = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
const CardFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
+101
View File
@@ -0,0 +1,101 @@
/** shadcn/ui 风格 Dialog(基于 @radix-ui/react-dialog)
* DialogContent 用 flex flex-col + overflow-x-hidden,适配移动端 w-[calc(100vw-1.5rem)] max-w-lg
*/
import { forwardRef } 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 = 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 = 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 flex w-[calc(100vw-1.5rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-x-hidden overflow-y-auto border border-border bg-background p-4 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 sm:p-6',
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 = 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 = 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,
}
+25
View File
@@ -0,0 +1,25 @@
/** shadcn/ui 风格 Input */
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-border 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}
/>
)
},
)
Input.displayName = 'Input'
+19
View File
@@ -0,0 +1,19 @@
/** shadcn/ui 风格 Label */
import { forwardRef } from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
export const Label = 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 = 'Label'
+40
View File
@@ -0,0 +1,40 @@
/** shadcn/ui 风格 ScrollArea(基于 @radix-ui/react-scroll-area) */
import { forwardRef } from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = 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 = 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 }
+94
View File
@@ -0,0 +1,94 @@
/** shadcn/ui 风格 Select(基于 @radix-ui/react-select) */
import { forwardRef } 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 = 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-lg border border-border 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 = 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-lg border border-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 = 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-md 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,
}
+24
View File
@@ -0,0 +1,24 @@
/** shadcn/ui 风格 Separator(基于 @radix-ui/react-separator) */
import { forwardRef } from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = 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 }
+6
View File
@@ -0,0 +1,6 @@
/** sonner Toaster(登录页独立全屏,需自带) */
import { Toaster as SonnerToaster } from 'sonner'
export function Toaster() {
return <SonnerToaster position="top-center" richColors closeButton />
}
+22
View File
@@ -0,0 +1,22 @@
/** shadcn/ui 风格 Textarea */
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-lg border border-border 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'
+80
View File
@@ -0,0 +1,80 @@
/* BossAi 2.0 全局样式 — 主色 indigo-600(信任+专业) */
@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%;
/* 2.0 主色:indigo-600 */
--primary: 243 75% 59%;
--primary-foreground: 210 40% 98%;
--secondary: 210 16% 93%;
--secondary-foreground: 222 47% 11%;
--muted: 210 16% 95%;
--muted-foreground: 215 16% 47%;
--accent: 243 75% 96%;
--accent-foreground: 243 75% 40%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214 20% 90%;
--input: 214 20% 90%;
--ring: 243 75% 59%;
--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: 243 75% 65%;
--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% 20%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 20%;
--input: 217 33% 20%;
--ring: 243 75% 68%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
/* 移动端底部 Tab 留出空间 */
@media (max-width: 767px) {
main {
padding-bottom: 4rem;
}
}
}
/* 打印样式 */
@media print {
header, nav, .no-print, .bottom-tab, .fab {
display: none !important;
}
.card {
box-shadow: none !important;
border: 1px solid hsl(var(--border)) !important;
}
}
+383
View File
@@ -0,0 +1,383 @@
/** BossAi 2.0 API 客户端
* 在 v1.0 基础上新增:案卡 CRUD、老板台聚合、我的团队、快捷指令、风险推送
* 共享同一后端,新增端点向后兼容
*/
import type {
AskResponse, EmotionMode, Expert, ExpertService, Consultation,
FileInspectResponse, HistoryMessage, HistorySession, KnowledgeDoc,
Scene, SessionMode, SessionResponse, Settings,
Matter, MatterBoard, DashboardToday, RiskAlert,
ExpertRelation, QuickCommands,
} from '@/types'
const BASE = '/api'
const DEFAULT_TIMEOUT = 30000
const LONG_TIMEOUT = 60000
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)
}
},
}
function authHeaders(): Record<string, string> {
const token = typeof window !== 'undefined' ? localStorage.getItem('bossai_token') : null
return token ? { Authorization: `Bearer ${token}` } : {}
}
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)
}
}
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 }))
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
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)
}
export const api = {
// ===== 基础 =====
health: () => request<{ status: string; service: string }>('/health'),
// ===== 认证 =====
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 }),
}),
// ===== 会话与提问(与 v1.0 兼容) =====
createSession: (mode: SessionMode) =>
request<SessionResponse>('/sessions', { method: 'POST', body: JSON.stringify({ mode }) }),
/** 检查内存 session 是否存活(后端重启后内存 session 会丢失) */
sessionAlive: (sid: string) =>
request<{ alive: boolean }>(`/sessions/${sid}/alive`),
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 }),
askAnalyze: (
sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode,
factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>, topic?: 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;
profile_facts?: Array<{ dim_key: string; dim_desc: string; answer: string }>;
element_template?: Record<string, unknown>;
}>(`/sessions/${sid}/ask-analyze`, {
method: 'POST',
body: JSON.stringify({ question, scene, emotion_mode: emotionMode, fact_answers: factAnswers, topic }),
}),
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 })
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>
},
// ===== 画像 =====
getProfile: () => requestWithRetry<Record<string, unknown>>('/profile/me', {}),
updateProfile: (data: {
industry?: string; company_size?: string; role?: string;
risk_tolerance?: string; decision_style?: string; key_facts?: Record<string, string>;
}) => request<{ updated: boolean }>('/profile/me', { method: 'PUT', body: JSON.stringify(data) }),
getProfileHistory: () =>
request<{ results: Array<{ id: number; version: number; profile: Record<string, unknown>; source: string; created_at: string }> }>('/profile/me/history'),
// ===== 要素化立案 =====
elementFiling: (sid: string, question: string, causeOfAction: string,
factAnswers: Array<{ dim_key: string; dim_desc: string; answer: string }>, template?: Record<string, unknown>,
) => request<{
cause_of_action: string;
elements: Array<{ key: string; label: string; value: string }>;
claims: string[]; filing_text: string;
}>(`/sessions/${sid}/element-filing`, {
method: 'POST',
body: JSON.stringify({ question, cause_of_action: causeOfAction, fact_answers: factAnswers, template }),
}),
// ===== 话题与示例 =====
getTopics: (scene: Scene = 'legal', existing?: string[]) =>
request<{ topics: Array<{ title: string; desc: string; samples: string[] }> }>('/topics', {
method: 'POST', body: JSON.stringify({ scene, existing }),
}),
generateSamples: (scene: Scene = 'legal', topic?: string, existing?: string[]) =>
request<{ samples: string[] }>('/samples', {
method: 'POST', body: JSON.stringify({ scene, topic, existing }),
}),
// ===== 知识库 =====
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[]; counts: Record<string, number>; total: number }>(`/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' }),
vectorSearchKnowledge: (q: string, limit?: number) =>
request<{ results: KnowledgeDoc[] }>(`/knowledge/vector-search?q=${encodeURIComponent(q)}${limit ? `&limit=${limit}` : ''}`),
// ===== 设置 =====
getSettings: () => request<{ settings: Settings }>('/settings'),
updateSettings: (settings: Settings) =>
request<{ updated: boolean; settings: Settings }>('/settings', { method: 'PUT', body: JSON.stringify({ settings }) }),
// ===== 专家库(v1.0 兼容) =====
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 }),
}),
cancelMyService: (serviceId: number) =>
request<{ cancelled: boolean }>(`/my/services/${serviceId}/cancel`, { method: 'POST' }),
// ===== 专家工作台 =====
expertWorkbench: () =>
request<{ expert: Expert; services: ExpertService[]; consultations: Consultation[] }>('/expert/workbench'),
acceptService: (serviceId: number) =>
request<{ accepted: boolean }>(`/expert/services/${serviceId}/accept`, { 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) }),
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, headers: {},
}, { timeout: 30000, retries: 0 })
},
adminListUsers: (q?: string) =>
request<{ results: Array<{ id: number; phone: string; name: string; status: string; created_at: string; message_count: number; last_active: string | null; last_scene: string | null }> }>(`/admin/users${q ? `?q=${encodeURIComponent(q)}` : ''}`),
adminResetPassword: (userId: number, password: string) =>
request<{ reset: boolean }>(`/admin/users/${userId}/reset-password`, { method: 'POST', body: JSON.stringify({ password }) }),
adminUpdateUserStatus: (userId: number, status: 'active' | 'inactive') =>
request<{ status: string }>(`/admin/users/${userId}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
adminStats: () => request<{
users: number; experts_active: number; experts_total: number;
services_active: number; consultations_total: number; consultations_pending: number;
}>('/admin/stats'),
adminAiUsage: () => request<Record<string, unknown>>('/admin/ai-usage'),
adminExpertActivity: () => request<Record<string, unknown>>('/admin/expert-activity'),
// ===== 对话历史 =====
listHistorySessions: (mode?: 'normal' | 'confidential') =>
request<{ results: HistorySession[] }>(`/history/sessions${mode ? `?mode=${mode}` : ''}`),
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' }),
// ===== 2.0 新增:案卡 =====
/** 案卡看板(按状态分组) */
getMatters: (status?: string) =>
request<{ board: MatterBoard; total: number }>(`/matters${status ? `?status=${status}` : ''}`),
/** 创建案卡(绑定会话) */
createMatter: (data: { scene: Scene; title: string; session_id?: string }) =>
request<Matter>('/matters', { method: 'POST', body: JSON.stringify(data) }),
/** 案卡详情 */
getMatter: (id: number) => request<Matter>(`/matters/${id}`),
/** 通过 session_id 查询案卡(断点续接时恢复 matterId) */
getMatterBySession: (sid: string) => request<Matter>(`/matters/by-session/${sid}`),
/** 更新案卡状态 */
updateMatterStatus: (id: number, status: Matter['status']) =>
request<{ updated: boolean; status: string }>(`/matters/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }),
/** 闭环案卡 */
closeMatter: (id: number, executionResult?: string, executionNote?: string) =>
request<{ closed: boolean; closed_at: string }>(`/matters/${id}/close`, {
method: 'POST', body: JSON.stringify({ execution_result: executionResult, execution_note: executionNote }),
}),
// ===== 2.0 新增:老板台 =====
getDashboardToday: () => request<DashboardToday>('/dashboard/today'),
// ===== 2.0 新增:我的团队 =====
getTeam: () => request<{ results: ExpertRelation[] }>('/team'),
// ===== 2.0 新增:快捷指令 =====
getQuickCommands: () => request<QuickCommands>('/quick-commands'),
// ===== 2.0 新增:风险推送 =====
getRiskAlerts: (unread?: boolean) =>
request<{ results: RiskAlert[] }>(`/risk-alerts${unread ? '?unread=true' : ''}`),
readRiskAlert: (id: number) =>
request<{ read: boolean }>(`/risk-alerts/${id}/read`, { method: 'POST' }),
scanRiskAlerts: () =>
request<{ scanned: number; created: number }>('/risk-alerts/scan', { method: 'POST' }),
// ===== 2.0 新增:决策日志 =====
getDecisionsLog: (month?: string) =>
request<{
results: Matter[]
by_month: Record<string, Matter[]>
stats: { total: number; executed: number; execution_rate: number }
}>(`/decisions-log${month ? `?month=${month}` : ''}`),
updateExecutionResult: (matterId: number, executionResult: string, executionNote?: string) =>
request<{ updated: boolean }>(`/matters/${matterId}/execution`, {
method: 'PATCH',
body: JSON.stringify({ execution_result: executionResult, execution_note: executionNote }),
}),
// ===== 可观测性 =====
getMetrics: () => request<Record<string, unknown>>('/metrics'),
}
+46
View File
@@ -0,0 +1,46 @@
/** 用户认证:token 管理 + 登录状态(与 v1.0 共享 localStorage key,无缝切换) */
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'
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()
}
+114
View File
@@ -0,0 +1,114 @@
/** BossAi 2.0 共享导航配置
* TASK-006:合并 Sidebar/MobileNav/BottomTab 的导航常量为单一来源
* 桌面 Sidebar 和移动端 BottomTab 消费同一配置,仅渲染层不同
*/
import type { Role } from './auth'
import type { ComponentType } from 'react'
import {
LayoutDashboard, ClipboardList, Scale, TrendingUp, Heart, Users,
BookOpen, History, User, Settings, ShieldCheck, FileText,
BarChart3, Cpu, MessageSquare, UserCog, Briefcase,
} from 'lucide-react'
export interface NavItem {
/** 路由路径 */
path: string
/** 显示标签 */
label: string
/** 图标组件 */
icon: ComponentType<{ className?: string }>
/** 分组(用于桌面 Sidebar 分区) */
group: string
/** 移动端底部 Tab 是否显示(仅 top-level) */
tab?: boolean
}
/** 用户端导航(老板) */
export const USER_NAV: NavItem[] = [
// 总览
{ path: '/', label: '老板台', icon: LayoutDashboard, group: '总览', tab: true },
// 我的事项
{ path: '/matters', label: '事项看板', icon: ClipboardList, group: '我的事项', tab: true },
// 智囊
{ path: '/legal', label: '法务参谋', icon: Scale, group: '智囊' },
{ path: '/decisions', label: '经营决策', icon: TrendingUp, group: '智囊' },
{ path: '/life', label: '生活助理', icon: Heart, group: '智囊' },
{ path: '/emotion', label: '情绪中心', icon: ShieldCheck, group: '智囊' },
// 我的资源
{ path: '/team', label: '我的团队', icon: Users, group: '我的资源', tab: true },
{ path: '/decisions-log', label: '决策日志', icon: FileText, group: '我的资源' },
{ path: '/knowledge', label: '知识库', icon: BookOpen, group: '我的资源' },
{ path: '/history', label: '对话历史', icon: History, group: '我的资源' },
{ path: '/profile', label: '我的画像', icon: User, group: '我的资源' },
// 系统
{ path: '/settings', label: '设置', icon: Settings, group: '系统' },
]
/** 移动端底部 Tab(从 USER_NAV 提取 tab=true 的项,最后一项为"我") */
export const MOBILE_TABS: NavItem[] = [
...USER_NAV.filter((n) => n.tab),
// "我"聚合页
{ path: '/me', label: '我', icon: UserCog, group: '我', tab: true },
]
/** 专家端导航 */
export const EXPERT_NAV: NavItem[] = [
{ path: '/workbench', label: '待确认服务', icon: Briefcase, group: '工作台' },
{ path: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, group: '工作台' },
{ path: '/workbench/replied', label: '已回复咨询', icon: History, group: '工作台' },
{ path: '/workbench/users', label: '服务中用户', icon: Users, group: '工作台' },
]
/** 管理端导航 */
export const ADMIN_NAV: NavItem[] = [
{ path: '/admin', label: '平台统计', icon: BarChart3, group: '管理后台' },
{ path: '/admin/ai-usage', label: 'AI 使用', icon: Cpu, group: '管理后台' },
{ path: '/admin/expert-activity', label: '专家沟通', icon: MessageSquare, group: '管理后台' },
{ path: '/admin/users', label: '用户管理', icon: Users, group: '管理后台' },
{ path: '/admin/experts', label: '专家管理', icon: UserCog, group: '管理后台' },
]
/** 按角色获取导航 */
export function getNavByRole(role: Role): NavItem[] {
switch (role) {
case 'expert': return EXPERT_NAV
case 'admin': return ADMIN_NAV
default: return USER_NAV
}
}
/** 按分组归类导航项(用于桌面 Sidebar 分区渲染) */
export function groupNav(items: NavItem[]): Array<{ group: string; items: NavItem[] }> {
const map = new Map<string, NavItem[]>()
for (const item of items) {
if (!map.has(item.group)) map.set(item.group, [])
map.get(item.group)!.push(item)
}
return Array.from(map, ([group, items]) => ({ group, items }))
}
/** Header 标题映射(补齐 v1.0 缺失的 /history /profile /admin/* ) */
export const HEADER_TITLES: Record<string, string> = {
'/': '老板台',
'/matters': '事项看板',
'/legal': '法务参谋',
'/decisions': '经营决策',
'/life': '生活助理',
'/emotion': '情绪中心',
'/team': '我的团队',
'/decisions-log': '决策日志',
'/knowledge': '知识库',
'/history': '对话历史',
'/profile': '我的画像',
'/me': '我',
'/settings': '设置',
'/workbench': '待确认服务',
'/workbench/pending': '待回复咨询',
'/workbench/replied': '已回复咨询',
'/workbench/users': '服务中用户',
'/admin': '平台统计',
'/admin/ai-usage': 'AI 使用统计',
'/admin/expert-activity': '专家沟通统计',
'/admin/users': '用户管理',
'/admin/experts': '专家管理',
}
+96
View File
@@ -0,0 +1,96 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** 合并 Tailwind 类名,处理冲突 */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** 将后端时间字符串(UTC 或带时区)格式化为北京时间(Asia/Shanghai) */
export function formatTime(dt: string | null | undefined): string {
if (!dt) return '-'
const d = new Date(dt)
if (isNaN(d.getTime())) return dt
return d.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
hour12: false,
})
}
/** 仅日期格式(北京时间) */
export function formatDate(dt: string | null | undefined): string {
if (!dt) return '-'
const d = new Date(dt)
if (isNaN(d.getTime())) return dt
return d.toLocaleDateString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
})
}
/** 相对时间:刚刚 / X分钟前 / X小时前 / X天前 / 日期 */
export function formatRelative(dt: string | null | undefined): string {
if (!dt) return '-'
const d = new Date(dt)
if (isNaN(d.getTime())) return dt
const now = new Date()
const diff = now.getTime() - d.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return formatDate(dt)
}
/** 时段问候语 */
export function greeting(): string {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 9) return '早上好'
if (h < 12) return '上午好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
if (h < 22) return '晚上好'
return '夜深了'
}
/** 案卡状态中文标签 */
export const MATTER_STATUS_LABELS: Record<string, string> = {
new: '待补充事实',
asking_facts: '追问中',
pending_confirm: '待确认',
analyzing: '分析中',
pending_expert: '待专家',
closed: '已闭环',
}
/** 案卡状态颜色类(Tailwind) */
export const MATTER_STATUS_COLORS: Record<string, string> = {
new: 'bg-blue-100 text-blue-700 border-blue-200',
asking_facts: 'bg-amber-100 text-amber-700 border-amber-200',
pending_confirm: 'bg-indigo-100 text-indigo-700 border-indigo-200',
analyzing: 'bg-purple-100 text-purple-700 border-purple-200',
pending_expert: 'bg-orange-100 text-orange-700 border-orange-200',
closed: 'bg-gray-100 text-gray-600 border-gray-200',
}
/** 场景中文标签 */
export const SCENE_LABELS: Record<string, string> = {
legal: '法务',
decision: '经营',
life: '生活',
emotion: '情绪',
}
/** 风险等级中文标签 */
export const RISK_LABELS: Record<string, string> = {
green: '低风险',
amber: '需关注',
orange: '较高风险',
red: '高风险',
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
</BrowserRouter>
</StrictMode>,
)
+509
View File
@@ -0,0 +1,509 @@
/** 对话历史页:按场景分组列表、查看详情、保密模式密码验证、删除会话 */
import { useEffect, useState, useCallback } from 'react'
import { toast } from 'sonner'
import {
History,
Lock,
Trash2,
Loader2,
MessageSquare,
Shield,
ChevronRight,
ArrowLeft,
BookOpen,
Sparkles,
} 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 { Markdown } from '@/components/common/Markdown'
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, formatTime } 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 DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
/** 场景中文映射 */
const SCENE_LABELS: Record<string, string> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
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">
<p className="font-medium">{currentSession.title || '未命名对话'}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
{currentSession.scene && (
<Badge variant="secondary" className="text-xs">
{SCENE_LABELS[currentSession.scene] || currentSession.scene}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
{' · '}{currentSession.message_count} 条消息
</span>
</div>
</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} 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}
title="暂无历史会话"
description="普通模式和保密模式的对话会保存在这里,阅后即焚不落库"
/>
) : (
<div className="space-y-6">
{/* 按场景分组,每组内按时间倒序 */}
{(() => {
// 定义场景顺序
const SCENE_ORDER = ['legal', 'decision', 'life', 'emotion']
// 按 scene 分组(无 scene 归为"其他")
const groups = new Map<string, HistorySession[]>()
for (const s of sessions) {
const key = s.scene || 'other'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(s)
}
// 每组内按 last_message_at 倒序
for (const items of groups.values()) {
items.sort((a, b) => {
const ta = a.last_message_at ? new Date(a.last_message_at).getTime() : 0
const tb = b.last_message_at ? new Date(b.last_message_at).getTime() : 0
return tb - ta
})
}
// 渲染:按 SCENE_ORDER 顺序,最后是 other
const orderedKeys = [...SCENE_ORDER.filter(k => groups.has(k)), ...['other']].filter(k => groups.has(k))
return orderedKeys.map((key) => (
<div key={key}>
<p className="mb-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
{SCENE_LABELS[key] || '其他'}
<span className="ml-1.5 text-muted-foreground/50">{groups.get(key)!.length}</span>
</p>
<div className="space-y-2">
{groups.get(key)!.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">
<p className="truncate text-sm font-medium">{session.title || '未命名对话'}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
<span className="text-xs text-muted-foreground">
{session.message_count} 条 · {formatTime(session.last_message_at)}
</span>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</CardContent>
</Card>
)
})}
</div>
</div>
))
})()}
</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')}>
{/* 消息正文:用户消息纯文本,AI 消息 Markdown 渲染 */}
{isUser ? (
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
) : (
<Markdown content={message.content} className="text-sm" />
)}
{/* 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>
)}
{/* 参考来源:知识库引用 tag 或 AI 提供 tag */}
{!isUser && (
<div className="mt-2 border-t border-border/30 pt-2">
<div className="mb-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<BookOpen className="h-3 w-3" /> 参考来源
</div>
{message.meta?.rag_context && message.meta.rag_context.length > 0 ? (
<div className="space-y-1">
{message.meta.rag_context.map((doc, i) => (
<div key={i} className="flex flex-wrap items-center gap-1 text-xs">
<span className="font-medium">{doc.title}</span>
{doc.doc_type && (
<Badge variant="secondary" className="text-[10px]">
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</Badge>
)}
{doc.region && (
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
)}
</div>
))}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="h-3 w-3" />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</div>
)}
</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>
)
}
+209
View File
@@ -0,0 +1,209 @@
/** 老板台(TASK-007 + TASK-024)
* 替代 v1.0 Dashboard,展示"今日要事":
* - 问候语
* - 风险推送(AI 主动提醒)
* - 高风险提醒
* - 待确认事项
* - 专家回复
* - 经营健康度
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertTriangle, ClipboardCheck, MessageSquare, Activity, ArrowRight, Radar } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState'
import { EmptyState } from '@/components/common/EmptyState'
import { api } from '@/lib/api'
import { greeting, formatRelative, SCENE_LABELS, RISK_LABELS } from '@/lib/utils'
import type { DashboardToday, RiskAlert } from '@/types'
export function Dashboard() {
const navigate = useNavigate()
const [data, setData] = useState<DashboardToday | null>(null)
const [alerts, setAlerts] = useState<RiskAlert[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = () => {
setLoading(true); setError('')
Promise.all([
api.getDashboardToday(),
api.getRiskAlerts(true).catch(() => ({ results: [] as RiskAlert[] })),
])
.then(([d, a]) => { setData(d); setAlerts(a.results); setLoading(false) })
.catch((e) => { setError(e.message); setLoading(false) })
}
useEffect(load, [])
const handleReadAlert = async (id: number) => {
try {
await api.readRiskAlert(id)
setAlerts((prev) => prev.filter((a) => a.id !== id))
} catch { /* 忽略 */ }
}
if (loading) return <LoadingState label="加载今日要事..." />
if (error) return <ErrorState message={error} onRetry={load} />
if (!data) return <EmptyState title="暂无数据" />
const hasContent = data.high_risks.length > 0 || data.pending_confirm.length > 0 || data.expert_replies.length > 0
return (
<PageLayout title={`${data.greeting || greeting()},老板`} description="今天有需要你关注的事">
{!hasContent && (
<EmptyState
title="今天暂无要事"
description="有新的事项或风险时会在这里提醒你"
icon={Activity}
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
/>
)}
<div className="space-y-4">
{/* 风险推送(AI 主动提醒) */}
{alerts.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="p-4">
<div className="mb-3 flex items-center gap-2 text-orange-700">
<Radar className="h-5 w-5" />
<span className="font-semibold">{alerts.length} 条风险提醒</span>
</div>
<div className="space-y-2">
{alerts.map((a) => (
<div key={a.id} className="flex items-start justify-between rounded-lg bg-white p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${a.level === 'red' ? 'bg-red-500' : a.level === 'orange' ? 'bg-orange-500' : 'bg-blue-500'}`} />
<span className="text-sm font-medium text-foreground">{a.title}</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.content}</p>
</div>
<div className="ml-2 flex shrink-0 gap-1">
<Button size="sm" variant="ghost" onClick={() => handleReadAlert(a.id)}>稍后</Button>
<Button size="sm" variant="outline" onClick={() => navigate('/knowledge')}>查看</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 高风险提醒 */}
{data.high_risks.length > 0 && (
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4">
<div className="mb-3 flex items-center gap-2 text-red-700">
<AlertTriangle className="h-5 w-5" />
<span className="font-semibold">{data.high_risks.length} 条高风险提醒</span>
</div>
<div className="space-y-2">
{data.high_risks.map((r) => (
<div key={r.matter_id} className="flex items-center justify-between rounded-lg bg-white p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{r.title}</span>
<span className="text-xs text-red-600">{RISK_LABELS[r.level]}</span>
</div>
<span className="text-xs text-muted-foreground">{SCENE_LABELS[r.scene]}</span>
</div>
<Button size="sm" variant="destructive" onClick={() => navigate(`/matters`)}>
立即处理
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 待确认事项 */}
{data.pending_confirm.length > 0 && (
<Card>
<CardContent className="p-4">
<div className="mb-3 flex items-center gap-2 text-foreground">
<ClipboardCheck className="h-5 w-5 text-indigo-600" />
<span className="font-semibold">{data.pending_confirm.length} 个事项待你确认</span>
</div>
<div className="space-y-2">
{data.pending_confirm.map((p) => (
<div key={p.matter_id} className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{p.title}</div>
<div className="text-xs text-muted-foreground">
{SCENE_LABELS[p.scene]} · {formatRelative(p.updated_at)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => navigate('/matters')}>
去确认 <ArrowRight className="ml-1 h-3 w-3" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 专家回复 */}
{data.expert_replies.length > 0 && (
<Card>
<CardContent className="p-4">
<div className="mb-3 flex items-center gap-2 text-foreground">
<MessageSquare className="h-5 w-5 text-emerald-600" />
<span className="font-semibold">{data.expert_replies.length} 条专家回复</span>
</div>
<div className="space-y-2">
{data.expert_replies.map((r) => (
<div key={r.consultation_id} className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{r.expert_name} · {r.expert_title}</div>
<div className="truncate text-xs text-muted-foreground">{r.snippet}</div>
<div className="text-xs text-muted-foreground">{formatRelative(r.replied_at)}</div>
</div>
<Button size="sm" variant="outline" onClick={() => navigate('/team')}>
查看回复
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 经营健康度 */}
<Card>
<CardContent className="p-4">
<div className="mb-3 flex items-center gap-2 text-foreground">
<Activity className="h-5 w-5 text-primary" />
<span className="font-semibold">你的经营健康度</span>
</div>
<div className="space-y-3">
{[
{ label: '法务', value: data.health_score.legal },
{ label: '经营', value: data.health_score.operation },
{ label: '合规', value: data.health_score.compliance },
].map((s) => (
<div key={s.label}>
<div className="mb-1 flex justify-between text-sm">
<span className="text-muted-foreground">{s.label}</span>
<span className="font-medium text-foreground">{s.value}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${s.value >= 70 ? 'bg-emerald-500' : s.value >= 40 ? 'bg-amber-500' : 'bg-red-500'}`}
style={{ width: `${s.value}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</PageLayout>
)
}
+124
View File
@@ -0,0 +1,124 @@
/** 经营决策页:话题选择 + 换一批 + ChatPanel
* scene='decision', 默认普通模式, 不显示风险卡
*/
import { useState, useCallback } from 'react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { TrendingUp, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
/** 话题类型 */
interface Topic {
title: string
desc: string
samples: string[]
}
/** 经营决策助手:初始兜底话题,换一批时 AI 动态生成 */
const FALLBACK_TOPICS: Topic[] = [
{ title: '扩张收缩', desc: '开店、关店、业务线', samples: ['现在适合扩张开第二家店吗?', '要不要关闭亏损的业务线?', '新业务线该自营还是加盟?', '应该先扩张还是先巩固?'] },
{ title: '项目承接', desc: '大单、长期合同', samples: ['是否应该接付款周期90天的大单?', '长期框架合同要不要签?', '预付款比例低的项目能接吗?', '客户要求独家合作利弊?'] },
{ title: '定价策略', desc: '涨价、降价、促销', samples: ['该不该降价15%抢竞品客户?', '产品涨价怎么跟客户沟通?', '促销活动会不会损害品牌?', '成本上涨如何调整定价?'] },
{ title: '招人裁员', desc: '招聘、解雇、绩效', samples: ['现在该招人还是裁人?', '裁掉绩效差的员工怎么操作?', '关键岗位招人还是培养?', '外包和正式员工怎么选?'] },
{ title: '融资合伙', desc: '融资、合伙、股权', samples: ['现在适合融资吗?', '合伙人要退股怎么谈条件?', '出让多少股权合适?', '银行贷款还是股权融资?'] },
{ title: '转型外包', desc: '转型、外包、调整', samples: ['业务转型方向怎么选?', '哪些业务可以外包?', '转型期间现金流怎么管理?', '外包质量怎么控?'] },
]
export function Decisions() {
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS)
const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
const [loadingTopics, setLoadingTopics] = useState(false)
/** 换一批:调用 AI 动态生成话题(避免重复) */
const refreshTopics = useCallback(async () => {
setLoadingTopics(true)
try {
const res = await api.getTopics('decision', topics.map((t) => t.title))
if (res.topics?.length > 0) {
setTopics(res.topics)
setSelectedTopic(0)
}
} catch {
// AI 不可用时保持当前话题
} finally {
setLoadingTopics(false)
}
}, [topics])
const currentTopic = topics[selectedTopic]
return (
<PageLayout
title="经营决策助手"
description="项目承接、扩张收缩、招人、降价、业务线调整、融资合伙。情景推演,把利弊讲透。"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
决策话题
</button>
<button
onClick={refreshTopics}
disabled={loadingTopics}
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
>
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
换一批
</button>
</div>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
{loadingTopics && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
AI 正在生成新话题…
</div>
)}
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
{topics.map((t, i) => (
<button
key={t.title + i}
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',
)}
>
<TrendingUp 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>
{currentTopic && (
<ChatPanel
scene="decision"
samples={currentTopic.samples}
topic={currentTopic.title}
defaultMode="normal"
placeholder={`描述你的${currentTopic.title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 提供情景推演和利弊分析,最终决策由老板做出。"
/>
)}
</PageLayout>
)
}
+209
View File
@@ -0,0 +1,209 @@
/** 决策日志(TASK-025)
* 闭环案卡按月分组展示,支持标记执行结果
*/
import { useEffect, useState, useCallback } from 'react'
import { FileText, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import { PageLayout } from '@/components/common/PageLayout'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState'
import { EmptyState } from '@/components/common/EmptyState'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { formatDate, SCENE_LABELS, RISK_LABELS } from '@/lib/utils'
import type { Matter } from '@/types'
interface DecisionsLogResponse {
results: Matter[]
by_month: Record<string, Matter[]>
stats: { total: number; executed: number; execution_rate: number }
}
const EXECUTION_ICONS: Record<string, typeof CheckCircle> = {
'顺利': CheckCircle,
'受阻': AlertCircle,
'未执行': XCircle,
}
const EXECUTION_COLORS: Record<string, string> = {
'顺利': 'text-emerald-600',
'受阻': 'text-amber-600',
'未执行': 'text-red-600',
}
export function DecisionsLog() {
const [data, setData] = useState<DecisionsLogResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedMatter, setSelectedMatter] = useState<Matter | null>(null)
const [execResult, setExecResult] = useState<string>('')
const [execNote, setExecNote] = useState('')
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
setLoading(true); setError('')
api.getDecisionsLog()
.then((d) => { setData(d); setLoading(false) })
.catch((e) => { setError(e.message); setLoading(false) })
}, [])
useEffect(load, [load])
const handleMarkResult = (matter: Matter) => {
setSelectedMatter(matter)
setExecResult(matter.execution_result || '')
setExecNote(matter.execution_note || '')
setDialogOpen(true)
}
const handleSaveResult = async () => {
if (!selectedMatter) return
setSaving(true)
try {
await api.updateExecutionResult(selectedMatter.id, execResult, execNote)
toast.success('执行结果已更新')
setDialogOpen(false)
load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
if (loading) return <LoadingState label="加载决策日志..." />
if (error) return <ErrorState message={error} onRetry={load} />
const months = data ? Object.keys(data.by_month).sort().reverse() : []
return (
<PageLayout
title="决策日志"
description={data ? `共 ${data.stats.total} 个决策 · 执行率 ${data.stats.execution_rate}%` : ''}
>
{!data || data.stats.total === 0 ? (
<EmptyState
title="还没有闭环的决策"
description="完成事项后会自动归档到决策日志,支持标记执行结果进行复盘"
icon={FileText}
/>
) : (
<div className="space-y-6">
{/* 月度统计条 */}
<Card>
<CardContent className="flex items-center gap-6 p-4">
<div className="text-center">
<div className="text-2xl font-bold text-foreground">{data.stats.total}</div>
<div className="text-xs text-muted-foreground">总决策</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{data.stats.executed}</div>
<div className="text-xs text-muted-foreground">顺利执行</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">{data.stats.execution_rate}%</div>
<div className="text-xs text-muted-foreground">执行率</div>
</div>
</CardContent>
</Card>
{/* 按月分组 */}
{months.map((month) => (
<div key={month}>
<h3 className="mb-2 text-sm font-medium text-muted-foreground">{month}</h3>
<div className="space-y-2">
{data.by_month[month].map((matter) => {
const ExecIcon = matter.execution_result ? EXECUTION_ICONS[matter.execution_result] : null
return (
<Card key={matter.id} className="transition-shadow hover:shadow-md">
<CardContent className="flex items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{matter.title}</span>
<Badge variant="outline" className="text-xs">{SCENE_LABELS[matter.scene]}</Badge>
{matter.risk_level && (
<Badge variant="outline" className={`text-xs ${EXECUTION_COLORS[matter.risk_level] || ''}`}>
{RISK_LABELS[matter.risk_level]}
</Badge>
)}
</div>
{matter.summary && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{matter.summary}</p>
)}
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDate(matter.closed_at)}</span>
{ExecIcon && matter.execution_result && (
<>
<span>·</span>
<span className={EXECUTION_COLORS[matter.execution_result]}>
<ExecIcon className="mr-1 inline h-3 w-3" />
{matter.execution_result}
</span>
</>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleMarkResult(matter)}>
标记结果
</Button>
</CardContent>
</Card>
)
})}
</div>
</div>
))}
</div>
)}
{/* 标记执行结果弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>标记决策执行结果</DialogTitle>
<DialogDescription>{selectedMatter?.title}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-3 gap-2">
{['顺利', '受阻', '未执行'].map((r) => (
<button
key={r}
className={`rounded-lg border p-3 text-sm transition ${
execResult === r
? 'border-primary bg-primary/5 text-primary ring-1 ring-primary/20'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
onClick={() => setExecResult(r)}
>
{r}
</button>
))}
</div>
<div>
<label className="mb-1 block text-sm text-muted-foreground">备注(可选)</label>
<Textarea
value={execNote}
onChange={(e) => setExecNote(e.target.value)}
placeholder="记录执行情况..."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
<Button onClick={handleSaveResult} disabled={saving || !execResult}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
)
}
+122
View File
@@ -0,0 +1,122 @@
/** 情绪中心页:三模式选择(冷静/陪伴/推动) + ChatPanel
* scene='emotion', 默认阅后即焚, 三模式选择
*/
import { useState } from 'react'
import { Brain, HeartHandshake, Zap, Phone, ChevronDown } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader } 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')
const [modesCollapsed, setModesCollapsed] = useState(false)
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">
<button
onClick={() => setModesCollapsed(!modesCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', modesCollapsed && '-rotate-90')} />
回复模式
</button>
</CardHeader>
{!modesCollapsed && (
<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}
footer="情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。"
/>
</PageLayout>
)
}
+404
View File
@@ -0,0 +1,404 @@
/** 知识库管理页:7 类分类,录入、检索、删除,要素表模板预览 */
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 },
{ value: 'element_template', label: '要素表模板', icon: FileText },
]
/** 来源英文 → 中文映射 */
const SOURCE_LABELS: Record<string, string> = {
seed: '系统内置',
manual: '手动录入',
import: '导入',
api: 'API 录入',
}
/** 法规知识库管理:7 类分类,录入、检索、删除 */
export function KnowledgeBase() {
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
const [counts, setCounts] = useState<Record<string, number>>({})
const [totalCount, setTotalCount] = useState(0)
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)
setCounts(res.counts || {})
setTotalCount(res.total || 0)
} 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')}
>
全部 <span className="ml-1 text-xs opacity-70">{totalCount}</span>
</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}
<span className="ml-1 text-xs opacity-70">{counts[t.value] || 0}</span>
</Button>
))}
</div>
)}
</CardContent>
</Card>
{/* 文档列表 */}
{loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error} onRetry={loadDocs} />
) : displayDocs.length === 0 ? (
<EmptyState
title={searchResults ? '未找到匹配文档' : '知识库为空'}
description={searchResults ? '尝试更换关键词' : '点击右上角新增文档,录入法规、案例、经营指南等知识'}
icon={BookOpen}
/>
) : (
<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>
{doc.doc_type !== 'element_template' && (
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
)}
{doc.doc_type === 'element_template' && (() => {
try {
const tmpl = JSON.parse(doc.content)
return (
<div className="mt-2 rounded border bg-muted/30 p-3 text-xs">
<p className="font-medium">案由:{tmpl.cause_of_action}</p>
<p className="mt-0.5 text-muted-foreground">适用:{tmpl.applicable}</p>
<p className="mt-2 font-medium">要素清单({tmpl.elements?.length || 0}项)</p>
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{tmpl.elements?.map((e: Record<string, string>, i: number) => (
<div key={i} className="flex items-center justify-between">
<span className="text-muted-foreground">{e.label}</span>
<span className="font-medium">{e.type}{e.required === '是' ? ' ·必填' : ''}</span>
</div>
))}
</div>
{tmpl.claims?.length > 0 && (
<>
<p className="mt-2 font-medium">诉讼请求</p>
<div className="mt-1 flex flex-wrap gap-1">
{tmpl.claims.map((c: string, i: number) => (
<Badge key={i} variant="outline" className="text-[10px]">{c}</Badge>
))}
</div>
</>
)}
</div>
)
} catch { return null }
})()}
<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>
)
}
+125
View File
@@ -0,0 +1,125 @@
/** 法务参谋页:话题选择 + 换一批 + ChatPanel
* scene='legal', 默认保密模式, 显示风险卡, 启用上传
*/
import { useState, useCallback } from 'react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Briefcase, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
/** 话题类型 */
interface Topic {
title: string
desc: string
samples: string[]
}
/** 法务参谋:初始兜底话题,换一批时 AI 动态生成 */
// 初始兜底话题(同步显示,无 loading)
const FALLBACK_TOPICS: Topic[] = [
{ title: '劳动用工', desc: '工伤、辞退、欠薪、社保', samples: ['员工工伤后能开除吗?', '员工被辞退要赔多少?', '员工不签合同有何风险?', '社保未缴全被查怎么办?'] },
{ title: '合同审查', desc: '违约、条款、效力', samples: ['合同违约金怎么约定?', '对方不履行合同怎么办?', '口头协议有法律效力吗?', '合同条款不公平能改吗?'] },
{ title: '债权债务', desc: '欠款、催收、诉讼', samples: ['客户拖欠货款3个月如何催收?', '借条怎么写才有效?', '债务人不还钱能报警吗?', '超过诉讼时效怎么办?'] },
{ title: '股东纠纷', desc: '股权、分红、退股', samples: ['合伙人要退股怎么谈?', '股东不出资怎么办?', '分红比例怎么定?', '小股东权益被侵害怎么办?'] },
{ title: '税务风险', desc: '稽查、发票、合规', samples: ['收到税务稽查通知怎么办?', '发票开错了怎么处理?', '个体户怎么交税最合理?', '税务罚款能减免吗?'] },
{ title: '诉讼仲裁', desc: '起诉、举证、执行', samples: ['打官司要准备哪些证据?', '仲裁和诉讼哪个更快?', '赢了官司对方不执行怎么办?', '诉讼费用怎么算?'] },
]
export function LegalAdvisor() {
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS)
const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
const [loadingTopics, setLoadingTopics] = useState(false)
/** 换一批:调用 AI 动态生成话题(避免重复) */
const refreshTopics = useCallback(async () => {
setLoadingTopics(true)
try {
const res = await api.getTopics('legal', topics.map((t) => t.title))
if (res.topics?.length > 0) {
setTopics(res.topics)
setSelectedTopic(0)
}
} catch {
// AI 不可用时保持当前话题
} finally {
setLoadingTopics(false)
}
}, [topics])
const currentTopic = topics[selectedTopic]
return (
<PageLayout
title="法务参谋"
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁。AI 输出不替代执业律师正式意见。"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
法务话题
</button>
<button
onClick={refreshTopics}
disabled={loadingTopics}
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
>
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
换一批
</button>
</div>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
{loadingTopics && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
AI 正在生成新话题…
</div>
)}
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
{topics.map((t, i) => (
<button
key={t.title + i}
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',
)}
>
<Briefcase 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>
{currentTopic && (
<ChatPanel
scene="legal"
samples={currentTopic.samples}
topic={currentTopic.title}
defaultMode="confidential"
placeholder={`描述你的${currentTopic.title}问题…`}
showRiskCard
enableUpload
footer="AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。重大风险事项请寻求执业专业人士复核。"
/>
)}
</PageLayout>
)
}
+124
View File
@@ -0,0 +1,124 @@
/** 生活助理页:话题选择 + 换一批 + ChatPanel
* scene='life', 默认保密模式
*/
import { useState, useCallback } from 'react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Heart, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
/** 话题类型 */
interface Topic {
title: string
desc: string
samples: string[]
}
/** 生活助理:初始兜底话题,换一批时 AI 动态生成 */
const FALLBACK_TOPICS: Topic[] = [
{ title: '家庭沟通', desc: '亲子、夫妻、关系', samples: ['怎么和青春期的孩子沟通?', '夫妻因工作吵架怎么办?', '家人不支持创业怎么说服?', '工作太忙忽略家人怎么办?'] },
{ title: '子女教育', desc: '升学、兴趣、规划', samples: ['孩子不想上学怎么办?', '兴趣班该报几个?', '要不要送孩子出国?', '孩子成绩下滑怎么沟通?'] },
{ title: '父母养老', desc: '养老、医疗、陪伴', samples: ['父母年纪大了养老方案怎么选?', '异地父母怎么照顾?', '养老院还是居家养老?', '父母医疗费用怎么分担?'] },
{ title: '睡眠与压力', desc: '失眠、焦虑、调节', samples: ['最近睡眠很差怎么改善?', '工作压力大怎么缓解?', '焦虑到影响工作怎么办?', '怎么判断需要看心理医生?'] },
{ title: '出差日程', desc: '出行、时间管理', samples: ['出差频繁怎么兼顾家庭?', '行程太满怎么优化?', '出差期间公司怎么管?', '如何减少无效出差?'] },
{ title: '健康习惯', desc: '体检、饮食、运动', samples: ['体检报告几项异常怎么办?', '久坐怎么改善健康?', '应酬多怎么控制饮食?', '没时间运动怎么办?'] },
]
export function LifeAssistant() {
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS)
const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
const [loadingTopics, setLoadingTopics] = useState(false)
/** 换一批:调用 AI 动态生成话题(避免重复) */
const refreshTopics = useCallback(async () => {
setLoadingTopics(true)
try {
const res = await api.getTopics('life', topics.map((t) => t.title))
if (res.topics?.length > 0) {
setTopics(res.topics)
setSelectedTopic(0)
}
} catch {
// AI 不可用时保持当前话题
} finally {
setLoadingTopics(false)
}
}, [topics])
const currentTopic = topics[selectedTopic]
return (
<PageLayout
title="生活助理"
description="家庭沟通、子女教育、父母养老、健康习惯、出行规划。老板的生活问题,也值得认真对待。"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
生活话题
</button>
<button
onClick={refreshTopics}
disabled={loadingTopics}
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
>
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
换一批
</button>
</div>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
{loadingTopics && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
AI 正在生成新话题…
</div>
)}
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
{topics.map((t, i) => (
<button
key={t.title + i}
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',
)}
>
<Heart 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>
{currentTopic && (
<ChatPanel
scene="life"
samples={currentTopic.samples}
topic={currentTopic.title}
defaultMode="confidential"
placeholder={`描述你的${currentTopic.title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 输出仅供参考,重大事项请咨询相关领域专业人士。"
/>
)}
</PageLayout>
)
}
+208
View File
@@ -0,0 +1,208 @@
/** 登录/注册页:手机号+密码,支持用户/专家/管理员三种角色(独立全屏,不用 PageLayout) */
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 { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner'
import { Loader2, ShieldCheck, Briefcase, Shield, User, UserCog } 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: '平台管理员' },
},
]
/** 登录/注册页:手机号+密码,角色Tab(user/expert/admin) */
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)
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-lg border border-dashed border-border bg-muted/30 p-3 text-center">
<p className="mb-2 text-xs text-muted-foreground">
{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>
{/* Login 页在 AppLayout 之外,需自带 Toaster 才能显示 toast */}
<Toaster />
</div>
)
}
+228
View File
@@ -0,0 +1,228 @@
/** 事项看板(TASK-008)
* 案卡按状态分列展示,点击查看详情(弹窗),支持断点续接
* 移动端横向滚动看板列
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { ClipboardList, Plus, Scale, TrendingUp, Heart, ShieldCheck, ArrowRight, Loader2 } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState'
import { EmptyState } from '@/components/common/EmptyState'
import { MatterCard } from '@/components/common/MatterCard'
import { api } from '@/lib/api'
import { cn, MATTER_STATUS_LABELS, MATTER_STATUS_COLORS, SCENE_LABELS, RISK_LABELS, formatRelative, formatDate } from '@/lib/utils'
import type { Matter, MatterBoard, MatterStatus, Scene } from '@/types'
/** 看板列定义(顺序即展示顺序) */
const COLUMNS: MatterStatus[] = ['new', 'asking_facts', 'pending_confirm', 'analyzing', 'pending_expert', 'closed']
const SCENE_ICONS: Record<Scene, typeof Scale> = {
legal: Scale,
decision: TrendingUp,
life: Heart,
emotion: ShieldCheck,
}
const RISK_COLORS: Record<string, string> = {
red: 'text-red-600 border-red-200 bg-red-50',
orange: 'text-orange-600 border-orange-200 bg-orange-50',
amber: 'text-amber-600 border-amber-200 bg-amber-50',
green: 'text-green-600 border-green-200 bg-green-50',
}
export function Matters() {
const navigate = useNavigate()
const [board, setBoard] = useState<MatterBoard | null>(null)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedMatter, setSelectedMatter] = useState<Matter | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const load = useCallback(() => {
setLoading(true); setError('')
api.getMatters()
.then((r) => { setBoard(r.board); setTotal(r.total); setLoading(false) })
.catch((e) => { setError(e.message); setLoading(false) })
}, [])
useEffect(load, [load])
/** 点击案卡:加载详情并弹出 */
const handleCardClick = (matter: Matter) => {
setSelectedMatter(matter)
setDetailLoading(true)
api.getMatter(matter.id)
.then((detail) => { setSelectedMatter(detail); setDetailLoading(false) })
.catch(() => { setDetailLoading(false) })
}
/** 继续处理:跳转对应场景(带 session_id + matter_id 断点续接) */
const handleContinue = () => {
if (!selectedMatter) return
const scene = selectedMatter.scene
if (selectedMatter.session_id) {
navigate(`/${scene}?sid=${selectedMatter.session_id}&mid=${selectedMatter.id}`)
} else {
navigate(`/${scene}`)
}
setSelectedMatter(null)
}
if (loading) return <LoadingState label="加载事项看板..." />
if (error) return <ErrorState message={error} onRetry={load} />
return (
<PageLayout
title="事项看板"
description={`共 ${total} 个事项`}
actions={
<Button size="sm" onClick={() => navigate('/legal')}>
<Plus className="mr-1 h-4 w-4" /> 新建咨询
</Button>
}
>
{total === 0 ? (
<EmptyState
title="还没有事项"
description="开始一次咨询,系统会自动为你创建案卡,追踪进度"
icon={ClipboardList}
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
/>
) : (
<div className="flex gap-4 overflow-x-auto pb-4 md:grid md:grid-cols-3 md:gap-4 md:overflow-visible lg:grid-cols-6">
{COLUMNS.map((status) => {
const items = board?.[status] || []
return (
<div key={status} className="w-64 shrink-0 md:w-auto">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{MATTER_STATUS_LABELS[status]}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{items.length}</span>
</div>
<div className="space-y-2 min-h-[100px] rounded-xl bg-muted/30 p-2">
{items.length === 0 ? (
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">暂无</div>
) : (
items.map((m) => (
<MatterCard key={m.id} matter={m} onClick={() => handleCardClick(m)} />
))
)}
</div>
</div>
)
})}
</div>
)}
{/* 案卡详情弹窗 */}
<Dialog open={!!selectedMatter} onOpenChange={(open) => !open && setSelectedMatter(null)}>
<DialogContent className="max-w-lg">
{/* 加载中时提供隐藏的 Title/Description 满足 Radix a11y 要求 */}
{detailLoading && (
<DialogHeader className="sr-only">
<DialogTitle>加载中</DialogTitle>
<DialogDescription>正在加载案卡详情</DialogDescription>
</DialogHeader>
)}
{detailLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : selectedMatter ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{(() => {
const Icon = SCENE_ICONS[selectedMatter.scene] || Scale
return <Icon className="h-5 w-5 text-primary" />
})()}
{selectedMatter.title}
</DialogTitle>
<DialogDescription>
{selectedMatter.code} · {SCENE_LABELS[selectedMatter.scene]}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 状态 + 风险等级 */}
<div className="flex flex-wrap items-center gap-2">
<span className={cn('inline-block rounded-full border px-3 py-1 text-xs font-medium', MATTER_STATUS_COLORS[selectedMatter.status])}>
{MATTER_STATUS_LABELS[selectedMatter.status]}
</span>
{selectedMatter.risk_level && (
<span className={cn('inline-block rounded-full border px-3 py-1 text-xs font-medium', RISK_COLORS[selectedMatter.risk_level] || '')}>
{RISK_LABELS[selectedMatter.risk_level]}风险
</span>
)}
</div>
{/* 摘要 */}
{selectedMatter.summary && (
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">事项摘要</p>
<p className="rounded-lg bg-muted/50 p-3 text-sm text-foreground">{selectedMatter.summary}</p>
</div>
)}
{/* 执行结果(已闭环) */}
{selectedMatter.status === 'closed' && selectedMatter.execution_result && (
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">执行结果</p>
<div className="rounded-lg border p-3">
<Badge variant="outline" className={cn(
'mb-1',
selectedMatter.execution_result === '顺利' ? 'text-emerald-600' :
selectedMatter.execution_result === '受阻' ? 'text-amber-600' : 'text-red-600'
)}>
{selectedMatter.execution_result}
</Badge>
{selectedMatter.execution_note && (
<p className="mt-1 text-sm text-muted-foreground">{selectedMatter.execution_note}</p>
)}
</div>
</div>
)}
{/* 时间信息 */}
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-muted-foreground">创建时间</span>
<p className="mt-0.5 font-medium">{formatDate(selectedMatter.created_at)}</p>
</div>
<div>
<span className="text-muted-foreground">更新时间</span>
<p className="mt-0.5 font-medium">{formatRelative(selectedMatter.updated_at)}</p>
</div>
{selectedMatter.closed_at && (
<div>
<span className="text-muted-foreground">闭环时间</span>
<p className="mt-0.5 font-medium">{formatDate(selectedMatter.closed_at)}</p>
</div>
)}
</div>
</div>
<DialogFooter>
{selectedMatter.status !== 'closed' ? (
<Button onClick={handleContinue} size="sm">
继续处理 <ArrowRight className="ml-1 h-3.5 w-3.5" />
</Button>
) : (
<Button variant="outline" onClick={() => navigate('/decisions-log')} size="sm">
查看决策日志 <ArrowRight className="ml-1 h-3.5 w-3.5" />
</Button>
)}
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
</PageLayout>
)
}
+36
View File
@@ -0,0 +1,36 @@
/** 移动端"我"聚合页:头像占位+入口列表 */
import { NavLink } from 'react-router-dom'
import { User, History, BookOpen, Settings as SettingsIcon, ChevronRight } from 'lucide-react'
import { getName } from '@/lib/auth'
export function Me() {
const name = getName()
const items = [
{ path: '/profile', label: '我的画像', icon: User },
{ path: '/history', label: '对话历史', icon: History },
{ path: '/knowledge', label: '知识库', icon: BookOpen },
{ path: '/settings', label: '设置', icon: SettingsIcon },
]
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-xl border border-border bg-card p-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
{name?.[0] || '老'}
</div>
<div>
<div className="font-semibold text-foreground">{name || '老板'}</div>
<div className="text-sm text-muted-foreground">中小企业老板</div>
</div>
</div>
<div className="rounded-xl border border-border bg-card divide-y divide-border">
{items.map((item) => (
<NavLink key={item.path} to={item.path} className="flex items-center gap-3 p-4 hover:bg-muted transition-colors">
<item.icon className="h-5 w-5 text-muted-foreground" />
<span className="flex-1 text-sm text-foreground">{item.label}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</NavLink>
))}
</div>
</div>
)
}
+535
View File
@@ -0,0 +1,535 @@
import { useEffect, useState, useCallback } from 'react'
import { UserCircle, History, 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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api'
import { cn, formatTime } from '@/lib/utils'
/** 用户画像数据 */
interface ProfileData {
phone: string
industry: string
company_size: string
role: string
common_scenes: string[]
common_topics: string[]
risk_tolerance: string
communication_style: string
decision_style: string
key_facts: Record<string, string>
interaction_count: number
last_active: string
}
/** 画像历史版本 */
interface ProfileHistory {
id: number
version: number
profile: Record<string, unknown>
source: string
created_at: string
}
/** 字段中文标签 */
const FIELD_LABELS: Record<string, string> = {
industry: '行业',
company_size: '企业规模',
role: '角色',
risk_tolerance: '风险偏好',
communication_style: '沟通偏好',
decision_style: '决策风格',
}
/** 企业规模映射 */
const SIZE_LABELS: Record<string, string> = {
micro: '微型(<10人)',
small: '小型(10-50人)',
medium: '中型(50-200人)',
large: '大型(200+人)',
}
/** 风险偏好映射 */
const RISK_LABELS: Record<string, string> = {
conservative: '保守型',
moderate: '稳健型',
aggressive: '进取型',
}
/** 角色映射 */
const ROLE_LABELS: Record<string, string> = {
owner: '老板/创始人',
manager: '管理者',
ceo: 'CEO',
director: '总监',
}
/** 决策风格映射 */
const DECISION_STYLE_LABELS: Record<string, string> = {
'data-driven': '数据驱动型',
intuitive: '直觉型',
collaborative: '协作型',
}
/** 沟通偏好映射 */
const COMMUNICATION_STYLE_LABELS: Record<string, string> = {
concise: '简洁',
detailed: '详细',
structured: '结构化',
}
/** 行业映射 */
const INDUSTRY_LABELS: Record<string, string> = {
retail: '零售',
manufacturing: '制造业',
tech: '科技',
service: '服务业',
finance: '金融',
realestate: '房地产',
education: '教育',
healthcare: '医疗健康',
construction: '建筑',
logistics: '物流',
agriculture: '农业',
hospitality: '餐饮酒店',
}
/** key_facts 的 key 中文映射 */
const KEY_FACT_LABELS: Record<string, string> = {
region: '地区',
company_type: '公司类型',
team_ability: '团队能力',
communication: '沟通情况',
medical_proof: '医疗证明',
contract_terms: '合同条款',
medical_period: '医疗期',
financial_status: '财务状况',
injury_situation: '工伤情况',
legal_procedures: '法律程序',
market_potential: '市场潜力',
alternative_option: '备选方案',
work_injury_details: '工伤详情',
alternative_solution: '替代方案',
work_injury_insurance: '工伤保险',
performance_assessment: '绩效评估',
relevant_legal_provisions: '相关法律条款',
employee_count: '员工人数',
revenue: '营收',
profit_status: '盈利状况',
business_model: '商业模式',
funding_stage: '融资阶段',
expansion_plan: '扩张计划',
competitor: '竞争对手',
customer_concentration: '客户集中度',
supply_chain: '供应链',
cash_flow: '现金流',
asset_status: '资产状况',
liability: '负债',
contract_dispute: '合同纠纷',
debt_amount: '债务金额',
tax_risk: '税务风险',
litigation_status: '诉讼状态',
shareholder_structure: '股权结构',
partnership: '合伙关系',
salary_status: '薪资状况',
social_security: '社保',
severance: '经济补偿',
working_years: '工作年限',
probation_period: '试用期',
termination_reason: '解雇原因',
overtime_status: '加班情况',
export_capability: '出口能力',
family_situation: '家庭情况',
health_status: '健康状况',
travel_plan: '出行计划',
important_event: '重要事项',
education_background: '教育背景',
stress_source: '压力来源',
personality: '性格特征',
work_time: '工作时间',
contract_status: '合同状态',
salary_calculation_basis: '薪资计算依据',
injury_detail: '受伤详情',
treatment_status: '治疗状况',
recovery_status: '康复状况',
compensation_claim: '赔偿诉求',
arbitration_status: '仲裁状态',
employment_relationship: '劳动关系',
wage_standard: '工资标准',
working_hours: '工时',
leave_record: '请假记录',
discipline_record: '违纪记录',
training_agreement: '培训协议',
confidentiality_agreement: '保密协议',
non_compete: '竞业限制',
ip_ownership: '知识产权归属',
data_protection: '数据保护',
compliance_status: '合规状况',
regulatory_requirement: '监管要求',
license_status: '许可状态',
audit_finding: '审计发现',
internal_control: '内部控制',
risk_management: '风险管理',
insurance_coverage: '保险覆盖',
claim_status: '理赔状态',
policy_detail: '保单详情',
premium_status: '保费状态',
beneficiary: '受益人',
coverage_amount: '保额',
deductible: '免赔额',
policy_period: '保险期间',
renewal_status: '续保状态',
cancellation_reason: '退保原因',
loan_purpose: '借款用途',
repayment_plan: '还款计划',
collateral: '抵押物',
guarantor_info: '担保人',
interest_rate: '利率',
penalty_rate: '罚息率',
overdue_days: '逾期天数',
collection_status: '催收状态',
settlement_status: '结算状态',
bankruptcy_status: '破产状态',
restructure_plan: '重组方案',
liquidation_status: '清算状态',
debt_restructuring: '债务重组',
asset_disposal: '资产处置',
business_transfer: '业务转让',
merger_status: '并购状态',
acquisition_status: '收购状态',
divestiture: '剥离',
joint_venture: '合资',
strategic_alliance: '战略联盟',
ip_valuation: '知识产权估值',
technology_transfer: '技术转让',
royalty_rate: '许可费率',
infringement_scope: '侵权范围',
damages_claim: '损害赔偿诉求',
injunction_status: '禁令状态',
invalidation_request: '无效宣告请求',
opposition_status: '异议状态',
licensing_term: '许可期限',
territory: '地域范围',
exclusivity: '排他性',
sublicensing: '再许可',
warranty_term: '保证条款',
indemnity: '补偿条款',
limitation_liability: '责任限制',
force_majeure: '不可抗力',
dispute_resolution: '争议解决',
governing_law: '适用法律',
jurisdiction: '管辖',
arbitration_clause: '仲裁条款',
mediation_status: '调解状态',
enforcement_status: '执行状态',
execution_status: '执行状况',
preservation_measure: '保全措施',
objection_status: '异议状态',
retrial_request: '再审请求',
appeal_status: '上诉状态',
court_level: '法院层级',
case_procedure: '案件程序',
hearing_date: '开庭日期',
evidence_status: '举证状况',
witness_status: '证人状况',
expert_evaluation: '专家鉴定',
judicial_interpretation: '司法解释',
precedent_case: '先例案例',
legislation_reference: '法律依据',
}
/** key_facts 的值中文映射 */
const KEY_FACT_VALUE_LABELS: Record<string, string> = {
yes: '是',
no: '否',
true: '是',
false: '否',
none: '无',
unknown: '未知',
'n/a': '不适用',
pending: '待定',
active: '活跃',
inactive: '停用',
profit: '盈利',
loss: '亏损',
expansion: '扩张',
contraction: '收缩',
stable: '稳定',
growth: '增长中',
declining: '下滑中',
...SIZE_LABELS,
...RISK_LABELS,
...ROLE_LABELS,
...INDUSTRY_LABELS,
}
/** 场景中文映射 */
const SCENE_LABELS: Record<string, string> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
/** 来源映射 */
const SOURCE_LABELS: Record<string, string> = {
auto: 'AI 自动提取',
manual: '用户确认',
}
/** 格式化字段值 */
function formatValue(key: string, value: string): string {
if (key === 'company_size') return SIZE_LABELS[value] || value
if (key === 'risk_tolerance') return RISK_LABELS[value] || value
if (key === 'role') return ROLE_LABELS[value] || value
if (key === 'decision_style') return DECISION_STYLE_LABELS[value] || value
if (key === 'communication_style') return COMMUNICATION_STYLE_LABELS[value] || value
if (key === 'industry') return INDUSTRY_LABELS[value] || value
return value
}
/** 翻译 key_facts 的 key */
function translateFactKey(key: string): string {
return KEY_FACT_LABELS[key.toLowerCase()] || key
}
/** 翻译 key_facts 的值 */
function translateFactValue(value: string): string {
const lower = value.toLowerCase().trim()
return KEY_FACT_VALUE_LABELS[lower] || value
}
export function MyProfile() {
const [profile, setProfile] = useState<ProfileData | null>(null)
const [history, setHistory] = useState<ProfileHistory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentHistory, setCurrentHistory] = useState<ProfileHistory | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [profileRes, historyRes] = await Promise.all([
api.getProfile(),
api.getProfileHistory(),
])
setProfile(profileRes as unknown as ProfileData)
setHistory(historyRes.results)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
if (loading) return <PageLayout title="我的画像"><LoadingState /></PageLayout>
if (error) return <PageLayout title="我的画像"><ErrorState message={error} onRetry={load} /></PageLayout>
if (!profile) return null
// ===== 历史详情视图 =====
if (currentHistory) {
const hp = currentHistory.profile as Record<string, unknown>
return (
<PageLayout
title={`画像版本 v${currentHistory.version}`}
description={`${SOURCE_LABELS[currentHistory.source] || currentHistory.source} · ${formatTime(currentHistory.created_at)}`}
actions={
<Button variant="outline" size="sm" onClick={() => setCurrentHistory(null)}>
<ArrowLeft className="h-3.5 w-3.5" /> 返回
</Button>
}
>
<Card>
<CardContent className="p-5">
<div className="space-y-3">
{/* 标准字段:多列网格 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{Object.entries(FIELD_LABELS).map(([key, label]) => {
const val = hp[key] as string
if (!val) return null
return (
<div key={key} className="flex items-center justify-between border-b pb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{formatValue(key, val)}</span>
</div>
)
})}
</div>
{Array.isArray(hp.common_topics) && (hp.common_topics as string[]).length > 0 && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1">
{(hp.common_topics as string[]).map((t, i) => (
<Badge key={i} variant="secondary" className="text-[10px] px-1 py-0">{String(t)}</Badge>
))}
</div>
</div>
)}
{Boolean(hp.key_facts && typeof hp.key_facts === 'object' && !Array.isArray(hp.key_facts) && Object.keys(hp.key_facts as Record<string, unknown>).length > 0) && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">关键事实</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{Object.entries(hp.key_facts as Record<string, string>).map(([k, v]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{translateFactKey(k)}</span>
<span className="font-medium">{translateFactValue(String(v))}</span>
</div>
))}
</div>
</div>
)}
{Array.isArray(hp.common_scenes) && (hp.common_scenes as string[]).length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">常用场景</span>
<div className="flex flex-wrap gap-1">
{(hp.common_scenes as string[]).map((s, i) => (
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
{SCENE_LABELS[String(s)] || String(s)}
</Badge>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</PageLayout>
)
}
// ===== 画像主视图 =====
return (
<PageLayout
title="我的画像"
description="系统根据你的对话累积的抽象特征,用于个性化回复。不存储对话原文。"
>
{/* 当前画像 */}
<Card>
<CardContent className="p-5">
<div className="mb-4 flex items-center gap-2">
<UserCircle className="h-5 w-5 text-primary" aria-hidden />
<h3 className="text-sm font-semibold">当前画像</h3>
<Badge variant="secondary" className="ml-auto text-xs">
交互 {profile.interaction_count} 次
</Badge>
</div>
<div className="space-y-3">
{/* 标准字段:多列网格 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{Object.entries(FIELD_LABELS).map(([key, label]) => {
const val = profile[key as keyof ProfileData] as string
if (!val) return null
return (
<div key={key} className="flex items-center justify-between border-b pb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{formatValue(key, val)}</span>
</div>
)
})}
</div>
{profile.common_topics && profile.common_topics.length > 0 && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1">
{profile.common_topics.map((t, i) => (
<Badge key={i} variant="secondary" className="text-[10px] px-1 py-0">{t}</Badge>
))}
</div>
</div>
)}
{profile.key_facts && Object.keys(profile.key_facts).length > 0 && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">关键事实</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{Object.entries(profile.key_facts).map(([k, v]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{translateFactKey(k)}</span>
<span className="font-medium">{translateFactValue(v)}</span>
</div>
))}
</div>
</div>
)}
{profile.common_scenes && profile.common_scenes.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">常用场景</span>
<div className="flex flex-wrap gap-1">
{profile.common_scenes.map((s, i) => (
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
{SCENE_LABELS[s] || s}
</Badge>
))}
</div>
</div>
)}
{profile.last_active && (
<p className="text-xs text-muted-foreground">
最后活跃:{formatTime(profile.last_active)}
</p>
)}
</div>
</CardContent>
</Card>
{/* 版本历史 */}
<div>
<div className="mb-2 flex items-center gap-2 px-1">
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
<h3 className="text-sm font-semibold">版本历史</h3>
<span className="text-xs text-muted-foreground">{history.length} 个版本</span>
</div>
{history.length === 0 ? (
<EmptyState
title="暂无历史版本"
description="每次画像更新都会记录版本快照"
icon={History}
/>
) : (
<div className="space-y-2">
{history.map((h) => (
<Card
key={h.id}
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() => setCurrentHistory(h)}
>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
h.source === 'manual' ? 'bg-blue-100 text-blue-700' : 'bg-muted text-muted-foreground',
)}>
<History className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">版本 v{h.version}</p>
<div className="mt-0.5 flex items-center gap-2">
<Badge
variant={h.source === 'manual' ? 'default' : 'secondary'}
className="text-[10px]"
>
{SOURCE_LABELS[h.source] || h.source}
</Badge>
<span className="text-xs text-muted-foreground">{formatTime(h.created_at)}</span>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</CardContent>
</Card>
))}
</div>
)}
</div>
</PageLayout>
)
}
+12
View File
@@ -0,0 +1,12 @@
/** 通用占位页:未实现路由的兜底 */
import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState'
import { FileQuestion } from 'lucide-react'
export function Placeholder({ title }: { title: string }) {
return (
<PageLayout title={title}>
<EmptyState title="页面未找到" description={`"${title}" 暂未实现`} icon={FileQuestion} />
</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 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
on ? 'translate-x-5' : 'translate-x-0',
)}
/>
</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>
)
}
+101
View File
@@ -0,0 +1,101 @@
/** 我的团队(TASK-017)
* 重构专家库为关系视图:咨询过的专家自动沉淀
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Users, MessageSquare, Plus } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState'
import { EmptyState } from '@/components/common/EmptyState'
import { ExpertAvatar } from '@/components/common/ExpertAvatar'
import { api } from '@/lib/api'
import { formatRelative, SCENE_LABELS } from '@/lib/utils'
import type { ExpertRelation } from '@/types'
export function Team() {
const navigate = useNavigate()
const [team, setTeam] = useState<ExpertRelation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = () => {
setLoading(true); setError('')
api.getTeam()
.then((r) => { setTeam(r.results); setLoading(false) })
.catch((e) => { setError(e.message); setLoading(false) })
}
useEffect(load, [])
if (loading) return <LoadingState label="加载我的团队..." />
if (error) return <ErrorState message={error} onRetry={load} />
return (
<PageLayout
title="我的团队"
description={`共 ${team.length} 位专家`}
actions={<Button variant="outline" size="sm" onClick={() => navigate('/legal')}><Plus className="mr-1 h-4 w-4" /> 找新专家</Button>}
>
{team.length === 0 ? (
<EmptyState
title="你还没有咨询过专家"
description="开始一次咨询,问专家时会自动将专家加入你的团队"
icon={Users}
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
/>
) : (
<div className="space-y-3">
{team.map((member) => (
<Card key={member.expert_id} className="transition-shadow hover:shadow-md">
<CardContent className="flex items-center gap-4 p-4">
<ExpertAvatar
name={member.name}
avatarUrl={member.avatar_url}
size={48}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{member.name}</span>
<span className="text-sm text-muted-foreground">{member.title}</span>
{member.online && (
<span className="flex items-center gap-1 text-xs text-emerald-600">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> 在线
</span>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{SCENE_LABELS[member.category] || member.category}</span>
<span>·</span>
<span>咨询 {member.consultation_count} 次</span>
{member.last_consulted_at && (
<>
<span>·</span>
<span>最近 {formatRelative(member.last_consulted_at)}</span>
</>
)}
</div>
{member.last_topic && (
<div className="mt-1 truncate text-xs text-muted-foreground">
上次话题: {member.last_topic}
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-2">
<Button
size="sm"
onClick={() => navigate(`/${member.category}`, { state: { expertId: member.expert_id, lastTopic: member.last_topic } })}
>
<MessageSquare className="mr-1 h-3.5 w-3.5" /> 新咨询
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</PageLayout>
)
}
+279
View File
@@ -0,0 +1,279 @@
/** BossAi 2.0 类型定义
* 在 v1.0 基础上扩展案卡(Matter)、风险推送(RiskAlert)、专家关系(ExpertRelation)
*/
/** 风险等级 */
export type RiskLevel = 'green' | 'amber' | 'orange' | 'red'
/** 隐私会话模式 */
export type SessionMode = 'normal' | 'confidential' | 'ephemeral'
/** 业务场景 */
export type Scene = 'legal' | 'decision' | 'life' | 'emotion'
/** 情绪模式 */
export type EmotionMode = 'calm' | 'companion' | 'push'
/** 案卡状态 */
export type MatterStatus = 'new' | 'asking_facts' | 'pending_confirm' | 'analyzing' | 'pending_expert' | 'closed'
/** 风险卡片 */
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
}
/** 会话响应 */
export interface SessionResponse {
session_id: string
mode: SessionMode
expires_at: string
}
/** 文件检查响应 */
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
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
}
/** 用户设置 */
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
title?: string
}
/** 对话历史消息 */
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
rag_context?: Array<{
title: string
doc_type?: string
region?: string | null
}>
}
created_at: string | null
}
// ===== 2.0 新增类型 =====
/** 案卡 */
export interface Matter {
id: number
code: string
user_phone: string
scene: Scene
title: string
status: MatterStatus
session_id: string | null
risk_level: RiskLevel | null
summary: string | null
closed_at: string | null
execution_result: '顺利' | '受阻' | '未执行' | null
execution_note: string | null
created_at: string
updated_at: string
}
/** 案卡看板分组 */
export interface MatterBoard {
new: Matter[]
asking_facts: Matter[]
pending_confirm: Matter[]
analyzing: Matter[]
pending_expert: Matter[]
closed: Matter[]
}
/** 老板台聚合数据 */
export interface DashboardToday {
greeting: string
high_risks: Array<{
matter_id: number
title: string
level: RiskLevel
scene: Scene
}>
pending_confirm: Array<{
matter_id: number
title: string
scene: Scene
updated_at: string
}>
expert_replies: Array<{
consultation_id: number
expert_name: string
expert_title: string
snippet: string
replied_at: string
}>
health_score: {
legal: number
operation: number
compliance: number
}
unread_alerts: number
}
/** 风险推送 */
export interface RiskAlert {
id: number
level: 'red' | 'orange' | 'blue'
title: string
content: string
source: string
related_matter_id: number | null
read_at: string | null
created_at: string
}
/** 专家关系(我的团队) */
export interface ExpertRelation {
expert_id: number
name: string
avatar_url: string | null
category: string
title: string
expertise: string
online: boolean
consultation_count: number
last_consulted_at: string | null
last_topic: string | null
tags: string | null
}
/** 快捷指令 */
export interface QuickCommand {
id: string
label: string
template: string
}
/** 快捷指令分组 */
export interface QuickCommands {
legal: QuickCommand[]
decision: QuickCommand[]
life: QuickCommand[]
profile_incomplete: boolean
incomplete_hint?: string
}