feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
- 新增 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:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Markdown 渲染组件:支持 GFM(表格/列表/删除线)+ 代码高亮。
|
||||
|
||||
* 用于 AI 回复的富文本展示,替代纯 whitespace-pre-wrap。
|
||||
* 流式输出时传入 streaming=true 显示光标。
|
||||
*/
|
||||
export function Markdown({
|
||||
content,
|
||||
streaming,
|
||||
className,
|
||||
}: {
|
||||
content: string
|
||||
streaming?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm max-w-none dark:prose-invert',
|
||||
// 继承父级文字色,不被 prose 默认色覆盖
|
||||
'prose-headings:text-foreground prose-p:text-foreground prose-li:text-foreground',
|
||||
'prose-strong:text-foreground prose-a:text-primary',
|
||||
// 紧凑间距
|
||||
'prose-headings:my-2 prose-headings:first:mt-0 prose-p:my-2',
|
||||
'prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5',
|
||||
'prose-pre:my-3 prose-code:before:content-none prose-code:after:content-none',
|
||||
'prose-table:my-3 prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
// 代码块:圆角 + 暗色背景
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
className="rounded-lg bg-slate-900 p-3 text-xs text-slate-100 overflow-x-auto"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// 行内代码:浅灰背景
|
||||
code: ({ children, className, ...props }) => {
|
||||
const isInline = !className?.includes('language-')
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-muted px-1 py-0.5 text-xs font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
// 表格:外层包裹 overflow-x-auto 防止小屏溢出
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full min-w-full border-collapse border text-xs"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="border bg-muted/50 font-semibold text-left"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td className="border" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
// 链接:新标签打开
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content + (streaming && content && !content.endsWith('\n') ? '\n' : '')}
|
||||
</ReactMarkdown>
|
||||
{streaming && (
|
||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-current align-text-bottom" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,6 @@
|
||||
/** sonner Toaster(登录页独立全屏,需自带) */
|
||||
import { Toaster as SonnerToaster } from 'sonner'
|
||||
|
||||
export function Toaster() {
|
||||
return <SonnerToaster position="top-center" richColors closeButton />
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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': '专家管理',
|
||||
}
|
||||
@@ -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: '高风险',
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user