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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-31 08:38:33 +08:00
co-authored by Devin
parent 7a788bf8dc
commit 4de36547cd
88 changed files with 15237 additions and 62 deletions
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
/** 空状态组件 */
import { cn } from '@/lib/utils'
import type { ComponentType } from 'react'
interface EmptyStateProps {
title: string
description?: string
icon?: ComponentType<{ className?: string }>
action?: React.ReactNode
className?: string
}
export function EmptyState({ title, description, icon: Icon, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{Icon && (
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
)}
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{description && <p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
/** 错误状态组件(带重试) */
import { AlertCircle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ErrorStateProps {
message: string
onRetry?: () => void
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="mb-3 h-10 w-10 text-destructive" />
<p className="text-sm text-foreground">{message}</p>
{onRetry && (
<Button variant="outline" size="sm" className="mt-4" onClick={onRetry}>
<RefreshCw className="mr-2 h-4 w-4" /> 重试
</Button>
)}
</div>
)
}
@@ -0,0 +1,34 @@
/** 专家头像组件(带 fallback 首字母) */
import { cn } from '@/lib/utils'
interface ExpertAvatarProps {
name: string
avatarUrl?: string | null
size?: number
className?: string
}
export function ExpertAvatar({ name, avatarUrl, size = 40, className }: ExpertAvatarProps) {
const initial = name?.[0] || '?'
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={name}
style={{ width: size, height: size }}
className={cn('rounded-full object-cover', className)}
/>
)
}
return (
<div
style={{ width: size, height: size, fontSize: size * 0.4 }}
className={cn(
'flex items-center justify-center rounded-full bg-primary/10 text-primary font-medium',
className,
)}
>
{initial}
</div>
)
}
@@ -0,0 +1,12 @@
/** 加载状态组件 */
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
export function LoadingState({ label = '加载中...', className }: { label?: string; className?: string }) {
return (
<div className={cn('flex items-center justify-center py-12', className)}>
<Loader2 className="mr-2 h-5 w-5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">{label}</span>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { cn } from '@/lib/utils'
/** Markdown 渲染组件:支持 GFM(表格/列表/删除线)+ 代码高亮。
* 用于 AI 回复的富文本展示,替代纯 whitespace-pre-wrap。
* 流式输出时传入 streaming=true 显示光标。
*/
export function Markdown({
content,
streaming,
className,
}: {
content: string
streaming?: boolean
className?: string
}) {
return (
<div
className={cn(
'prose prose-sm max-w-none dark:prose-invert',
// 继承父级文字色,不被 prose 默认色覆盖
'prose-headings:text-foreground prose-p:text-foreground prose-li:text-foreground',
'prose-strong:text-foreground prose-a:text-primary',
// 紧凑间距
'prose-headings:my-2 prose-headings:first:mt-0 prose-p:my-2',
'prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5',
'prose-pre:my-3 prose-code:before:content-none prose-code:after:content-none',
'prose-table:my-3 prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1',
className,
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
// 代码块:圆角 + 暗色背景
pre: ({ children, ...props }) => (
<pre
className="rounded-lg bg-slate-900 p-3 text-xs text-slate-100 overflow-x-auto"
{...props}
>
{children}
</pre>
),
// 行内代码:浅灰背景
code: ({ children, className, ...props }) => {
const isInline = !className?.includes('language-')
if (isInline) {
return (
<code
className="rounded bg-muted px-1 py-0.5 text-xs font-mono"
{...props}
>
{children}
</code>
)
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
// 表格:外层包裹 overflow-x-auto 防止小屏溢出
table: ({ children, ...props }) => (
<div className="overflow-x-auto">
<table
className="w-full min-w-full border-collapse border text-xs"
{...props}
>
{children}
</table>
</div>
),
th: ({ children, ...props }) => (
<th
className="border bg-muted/50 font-semibold text-left"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }) => (
<td className="border" {...props}>
{children}
</td>
),
// 链接:新标签打开
a: ({ children, href, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
{...props}
>
{children}
</a>
),
}}
>
{content + (streaming && content && !content.endsWith('\n') ? '\n' : '')}
</ReactMarkdown>
{streaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-current align-text-bottom" aria-hidden />
)}
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
/** 案卡组件(TASK-009)
* 公共组件:展示单张案卡,支持点击和归档
*/
import { Scale, TrendingUp, Heart, ShieldCheck, Archive } from 'lucide-react'
import { cn, MATTER_STATUS_LABELS, MATTER_STATUS_COLORS, SCENE_LABELS, formatRelative } from '@/lib/utils'
import type { Matter, Scene } from '@/types'
const SCENE_ICONS: Record<Scene, typeof Scale> = {
legal: Scale,
decision: TrendingUp,
life: Heart,
emotion: ShieldCheck,
}
interface MatterCardProps {
matter: Matter
onClick?: () => void
onArchive?: () => void
}
export function MatterCard({ matter, onClick, onArchive }: MatterCardProps) {
const Icon = SCENE_ICONS[matter.scene] || Scale
return (
<div
onClick={onClick}
className={cn(
'group cursor-pointer rounded-xl border bg-card p-3 shadow-sm transition-all hover:shadow-md',
MATTER_STATUS_COLORS[matter.status],
)}
>
<div className="flex items-start gap-2">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/60">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{matter.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{SCENE_LABELS[matter.scene]}</span>
<span>·</span>
<span>{formatRelative(matter.updated_at)}</span>
</div>
<div className="mt-2">
<span className={cn('inline-block rounded-full border px-2 py-0.5 text-xs', MATTER_STATUS_COLORS[matter.status])}>
{MATTER_STATUS_LABELS[matter.status]}
</span>
</div>
</div>
{onArchive && matter.status === 'closed' && (
<button
onClick={(e) => { e.stopPropagation(); onArchive() }}
className="opacity-0 transition-opacity group-hover:opacity-100"
aria-label="归档"
>
<Archive className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
</div>
)
}
@@ -0,0 +1,16 @@
/** 离线提示条 */
import { useEffect, useState } from 'react'
import { WifiOff } from 'lucide-react'
import { network } from '@/lib/api'
export function OfflineIndicator() {
const [online, setOnline] = useState(network.isOnline())
useEffect(() => network.onChange(setOnline), [])
if (online) return null
return (
<div className="flex items-center justify-center gap-2 bg-amber-500 px-4 py-2 text-sm text-white">
<WifiOff className="h-4 w-4" />
网络已断开,部分功能不可用
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
/** 页面布局骨架:统一标题+描述+操作区+内容 */
import { cn } from '@/lib/utils'
interface PageLayoutProps {
title: string
description?: string
actions?: React.ReactNode
children: React.ReactNode
className?: string
}
export function PageLayout({ title, description, actions, children, className }: PageLayoutProps) {
return (
<div className={cn('space-y-6', className)}>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-foreground md:text-2xl">{title}</h2>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
{children}
</div>
)
}
@@ -0,0 +1,201 @@
/** 快捷指令面板(TASK-014 + TASK-015)
* FAB 点击弹出底部抽屉,画像驱动的指令网格 + 语音/文字输入
*/
import { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Scale, TrendingUp, Heart, Mic, X, Send, Sparkles } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import type { QuickCommands, Scene } from '@/types'
interface QuickCommandPanelProps {
open: boolean
onClose: () => void
}
const SCENE_ICONS: Record<string, typeof Scale> = {
legal: Scale,
decision: TrendingUp,
life: Heart,
}
const SCENE_ROUTES: Record<string, string> = {
legal: '/legal',
decision: '/decisions',
life: '/life',
}
export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
const navigate = useNavigate()
const [commands, setCommands] = useState<QuickCommands | null>(null)
const [loading, setLoading] = useState(false)
const [textInput, setTextInput] = useState('')
const [listening, setListening] = useState(false)
const recognitionRef = useRef<any>(null)
/** 加载快捷指令 */
useEffect(() => {
if (open && !commands) {
setLoading(true)
api.getQuickCommands()
.then((c) => { setCommands(c); setLoading(false) })
.catch(() => setLoading(false))
}
}, [open, commands])
/** 点击指令:预填模板跳转对应页面 */
const handleCommandClick = (scene: string, template: string) => {
navigate(`${SCENE_ROUTES[scene] || '/legal'}?q=${encodeURIComponent(template)}`)
onClose()
}
/** 直接文字输入 */
const handleTextSubmit = () => {
if (!textInput.trim()) return
navigate(`/legal?q=${encodeURIComponent(textInput.trim())}`)
onClose()
setTextInput('')
}
/** 语音输入(Web Speech API) */
const startListening = () => {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
// 不支持语音识别,静默降级
return
}
if (listening) {
recognitionRef.current?.stop()
setListening(false)
return
}
const recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.continuous = false
recognition.interimResults = true
recognition.onresult = (event: any) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
setTextInput(transcript)
}
recognition.onerror = () => setListening(false)
recognition.onend = () => setListening(false)
recognition.start()
recognitionRef.current = recognition
setListening(true)
}
if (!open) return null
const sections: Array<{ key: Scene; label: string }> = [
{ key: 'legal', label: '法务' },
{ key: 'decision', label: '经营' },
{ key: 'life', label: '生活' },
]
return (
<>
{/* 遮罩 */}
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={onClose}
/>
{/* 底部抽屉 */}
<div className={cn(
'fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-card shadow-xl md:hidden',
'animate-slide-up',
)}>
{/* 顶部把手 + 关闭 */}
<div className="flex items-center justify-between border-b border-border p-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<span className="font-medium text-foreground">你需要什么帮助?</span>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4">
{/* 画像不完整提示 */}
{commands?.profile_incomplete && commands.incomplete_hint && (
<div className="mb-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
{commands.incomplete_hint}
</div>
)}
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">加载中...</div>
) : (
<>
{/* 指令分组 */}
{sections.map(({ key, label }) => {
const items = (commands?.[key as keyof QuickCommands] as Array<{ id: string; label: string; template: string }>) || []
if (items.length === 0) return null
const Icon = SCENE_ICONS[key] || Scale
return (
<div key={key} className="mb-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="h-4 w-4" />
{label}
</div>
<div className="grid grid-cols-3 gap-2">
{items.map((cmd) => (
<button
key={cmd.id}
onClick={() => handleCommandClick(key, cmd.template)}
className="rounded-xl border border-border bg-card p-3 text-center text-xs text-foreground transition hover:border-primary hover:bg-primary/5"
>
{cmd.label}
</button>
))}
</div>
</div>
)
})}
</>
)}
{/* 文字/语音输入 */}
<div className="mt-4 border-t border-border pt-4">
<div className="flex items-center gap-2">
<button
onClick={startListening}
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition',
listening
? 'bg-red-500 text-white animate-pulse'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
aria-label="语音输入"
>
<Mic className="h-5 w-5" />
</button>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleTextSubmit()}
placeholder={listening ? '正在聆听...' : '或直接输入你的问题...'}
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={handleTextSubmit}
disabled={!textInput.trim()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground disabled:opacity-50"
aria-label="发送"
>
<Send className="h-4 w-4" />
</button>
</div>
{listening && (
<p className="mt-2 text-center text-xs text-muted-foreground">点击麦克风停止</p>
)}
</div>
</div>
</div>
</>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { AlertOctagon, FileText, ListChecks, Users, Cloud, Cpu, AlertTriangle, BookOpen, Sparkles } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { StatusBadge } from './StatusBadge'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Markdown } from './Markdown'
import { cn } from '@/lib/utils'
import type { RiskCard, KnowledgeDoc } from '@/types'
/** 知识库文档类型标签映射 */
const DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
const BORDER: Record<string, string> = {
green: 'border-l-emerald-500',
amber: 'border-l-amber-500',
orange: 'border-l-orange-500',
red: 'border-l-rose-500',
}
interface RiskCardViewProps {
card: RiskCard
answer?: string | null
model?: string
inferenceLocation?: string
fallbackUsed?: boolean
/** 知识库引用来源 */
ragContext?: KnowledgeDoc[]
/** 流式输出中(显示光标) */
streaming?: boolean
onEscalate?: () => void
}
/** 风险卡片:等级 + 结论 + 行动 + 证据 + 升级提示 + 模型答复 + 推理位置 + 参考来源 */
export function RiskCardView({ card, answer, model, inferenceLocation, fallbackUsed, ragContext, streaming, onEscalate }: RiskCardViewProps) {
return (
<Card className={cn('border-l-4', BORDER[card.level])}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-base">风险判断</CardTitle>
<div className="flex items-center gap-2">
{inferenceLocation && (
<span className={cn(
'flex items-center gap-1 text-xs',
fallbackUsed ? 'text-amber-600' : 'text-muted-foreground',
)}>
{inferenceLocation === 'cloud' ? <Cloud className="h-3 w-3" /> : <Cpu className="h-3 w-3" />}
{inferenceLocation === 'cloud' ? '云端推理' : '本地推理'}
{fallbackUsed && <AlertTriangle className="h-3 w-3" />}
</span>
)}
{model && !inferenceLocation && (
<span className="text-xs text-muted-foreground">
{model === 'kimi' ? '云端模型' : '本地分诊'}
</span>
)}
<StatusBadge level={card.level} />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-relaxed">{card.conclusion}</p>
{card.actions.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" aria-hidden /> 立即行动
</h4>
<ul className="space-y-1.5">
{card.actions.map((a, i) => (
<li key={i} className="flex gap-2 text-sm">
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-foreground/40" />
{a}
</li>
))}
</ul>
</div>
)}
{card.evidence.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<FileText className="h-3.5 w-3.5" aria-hidden /> 需保存证据
</h4>
<p className="text-sm text-muted-foreground">{card.evidence.join('、')}</p>
</div>
)}
{answer && (
<div className="rounded-md bg-muted/50 p-3">
{/* 标题行:模型分析 + 参考来源 tag */}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-semibold text-muted-foreground">模型分析</h4>
{/* 参考来源 tag:有知识库引用时展示文档 tag,无引用时标注 AI 提供 */}
{!streaming && (
<div className="flex flex-wrap items-center gap-1.5">
{ragContext && ragContext.length > 0 ? (
<>
<BookOpen className="h-3 w-3 text-muted-foreground" aria-hidden />
{ragContext.map((doc, i) => (
<span key={i} className="flex items-center gap-1 text-[10px]">
<span className="font-medium text-foreground">{doc.title}</span>
{doc.doc_type && (
<Badge variant="secondary" className="text-[10px]">
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</Badge>
)}
{doc.region && (
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
)}
</span>
))}
</>
) : (
<>
<Sparkles className="h-3 w-3 text-muted-foreground" aria-hidden />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</>
)}
</div>
)}
</div>
<Markdown content={answer} streaming={streaming} className="text-sm" />
{/* 问专家按钮:每次对话都显示 */}
{!streaming && onEscalate && (
<div className="mt-3 border-t pt-2">
<Button size="sm" variant="outline" onClick={onEscalate}>
<Users className="h-3.5 w-3.5" /> 问专家
</Button>
</div>
)}
</div>
)}
{card.escalation && (
<div className="flex flex-wrap items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
<AlertOctagon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-900">建议人工专业复核</p>
<p className="mt-0.5 text-xs text-amber-700">
本事项可能造成明显损失或涉及重大风险,AI 输出不替代执业律师/会计师正式意见。
</p>
</div>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,22 @@
import { AlertTriangle, ShieldAlert, ShieldCheck, CircleAlert } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { RiskLevel } from '@/types'
const MAP: Record<RiskLevel, { label: string; cls: string; Icon: typeof ShieldCheck }> = {
green: { label: '绿色', cls: 'bg-emerald-100 text-emerald-700 border-emerald-200', Icon: ShieldCheck },
amber: { label: '黄色', cls: 'bg-amber-100 text-amber-700 border-amber-200', Icon: CircleAlert },
orange: { label: '橙色', cls: 'bg-orange-100 text-orange-700 border-orange-200', Icon: AlertTriangle },
red: { label: '红色', cls: 'bg-rose-100 text-rose-700 border-rose-200', Icon: ShieldAlert },
}
/** 风险等级徽章:统一呈现 green/amber/orange/red */
export function StatusBadge({ level, className }: { level: RiskLevel; className?: string }) {
const { label, cls, Icon } = MAP[level]
return (
<Badge variant="outline" className={cn(cls, 'gap-1', className)}>
<Icon className="h-3.5 w-3.5" aria-hidden />
{label}风险
</Badge>
)
}
+30
View File
@@ -0,0 +1,30 @@
/** BossAi 2.0 应用布局
* 桌面:左侧 Sidebar + 顶部 Header + 内容
* 移动:顶部 Header(精简) + 内容 + 底部 Tab + FAB
*/
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import { BottomTab } from './BottomTab'
import { Fab } from './Fab'
import { OfflineIndicator } from '@/components/common/OfflineIndicator'
import { Toaster } from 'sonner'
export function AppLayout() {
return (
<div className="min-h-screen bg-[#f8f9fb]">
<OfflineIndicator />
<Sidebar />
<div className="md:pl-60">
<Header />
<main className="container mx-auto max-w-7xl px-4 py-6">
<Outlet />
</main>
</div>
{/* 移动端底部 Tab + FAB */}
<BottomTab />
<Fab />
<Toaster position="top-center" richColors />
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
/** 移动端底部 Tab(仅 md 以下显示)
* 消费共享 NavConfig 的 MOBILE_TABS
*/
import { NavLink } from 'react-router-dom'
import { MOBILE_TABS } from '@/lib/nav-config'
import { cn } from '@/lib/utils'
export function BottomTab() {
return (
<nav className="bottom-tab fixed bottom-0 left-0 right-0 z-30 flex h-16 items-center justify-around border-t border-border bg-white/95 backdrop-blur md:hidden">
{MOBILE_TABS.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex flex-1 flex-col items-center gap-0.5 py-1 text-xs transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground',
)
}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
)
}
+22
View File
@@ -0,0 +1,22 @@
/** 浮动新建按钮(FAB)
* 移动端全局浮动,点击弹出快捷指令面板
*/
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { QuickCommandPanel } from '@/components/common/QuickCommandPanel'
export function Fab() {
const [open, setOpen] = useState(false)
return (
<>
<button
onClick={() => setOpen(true)}
className="fab fixed bottom-20 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 md:hidden"
aria-label="新建咨询"
>
<Plus className="h-6 w-6" />
</button>
<QuickCommandPanel open={open} onClose={() => setOpen(false)} />
</>
)
}
+41
View File
@@ -0,0 +1,41 @@
/** 顶部 Header(h-14 sticky)
* 桌面:显示当前页面标题 + 用户信息
* 移动:显示 Logo + 通知铃铛
*/
import { useLocation } from 'react-router-dom'
import { Bell } from 'lucide-react'
import { HEADER_TITLES } from '@/lib/nav-config'
import { getName } from '@/lib/auth'
export function Header() {
const { pathname } = useLocation()
const name = getName()
// 匹配标题(支持子路由,取最长匹配)
const title = Object.entries(HEADER_TITLES)
.filter(([p]) => pathname === p || (p !== '/' && pathname.startsWith(p)))
.sort((a, b) => b[0].length - a[0].length)[0]?.[1] || '左右 BossAi'
return (
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-white/95 px-4 backdrop-blur md:px-6">
{/* 移动端 Logo */}
<div className="flex items-center gap-2 md:hidden">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-xs">
左
</div>
</div>
<h1 className="flex-1 truncate text-base font-semibold text-foreground md:text-lg">{title}</h1>
{/* 通知铃铛 */}
<button
className="relative flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="通知"
>
<Bell className="h-5 w-5" />
{/* 角标占位(阶段三接入未读数) */}
</button>
{/* 用户名(桌面) */}
{name && (
<span className="hidden text-sm text-muted-foreground md:inline">{name}</span>
)}
</header>
)
}
+52
View File
@@ -0,0 +1,52 @@
/** 桌面端左侧导航(仅 md 以上显示)
* 消费共享 NavConfig,不再维护独立常量
*/
import { NavLink } from 'react-router-dom'
import { getNavByRole, groupNav } from '@/lib/nav-config'
import { getRole } from '@/lib/auth'
import { cn } from '@/lib/utils'
export function Sidebar() {
const role = getRole()
const nav = getNavByRole(role)
const groups = groupNav(nav)
return (
<aside className="hidden md:flex fixed left-0 top-0 z-30 h-screen w-60 flex-col border-r border-border bg-white">
{/* Logo */}
<div className="flex h-14 items-center gap-2 border-b border-border px-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
左
</div>
<span className="font-semibold text-foreground">左右 BossAi</span>
<span className="ml-auto text-xs text-muted-foreground">2.0</span>
</div>
{/* 导航 */}
<nav className="flex-1 overflow-y-auto py-4">
{groups.map(({ group, items }) => (
<div key={group} className="mb-4">
<div className="px-5 py-1 text-xs font-medium text-muted-foreground">{group}</div>
{items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-5 py-2 text-sm transition-colors',
isActive
? 'bg-primary/10 text-primary font-medium border-r-2 border-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)
}
>
<item.icon className="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</NavLink>
))}
</div>
))}
</nav>
</aside>
)
}
+29
View File
@@ -0,0 +1,29 @@
/** shadcn/ui 风格 Badge */
import { forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
},
},
defaultVariants: { variant: 'default' },
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
),
)
Badge.displayName = 'Badge'
+41
View File
@@ -0,0 +1,41 @@
/** shadcn/ui 风格 Button(复制自 v1.0,主色随 2.0 indigo) */
import { forwardRef } from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-border bg-background hover:bg-muted hover:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-muted hover:text-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-lg px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
},
)
Button.displayName = 'Button'
+47
View File
@@ -0,0 +1,47 @@
/** shadcn/ui 风格 Card */
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
const Card = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('card rounded-xl border border-border bg-card text-card-foreground shadow-sm', className)} {...props} />
),
)
Card.displayName = 'Card'
const CardHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
const CardDescription = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
)
CardDescription.displayName = 'CardDescription'
const CardContent = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
const CardFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
+101
View File
@@ -0,0 +1,101 @@
/** shadcn/ui 风格 Dialog(基于 @radix-ui/react-dialog)
* DialogContent 用 flex flex-col + overflow-x-hidden,适配移动端 w-[calc(100vw-1.5rem)] max-w-lg
*/
import { forwardRef } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 flex w-[calc(100vw-1.5rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-x-hidden overflow-y-auto border border-border bg-background p-4 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg sm:p-6',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+25
View File
@@ -0,0 +1,25 @@
/** shadcn/ui 风格 Input */
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm ring-offset-background',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = 'Input'
+19
View File
@@ -0,0 +1,19 @@
/** shadcn/ui 风格 Label */
import { forwardRef } from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
export const Label = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
))
Label.displayName = 'Label'
+40
View File
@@ -0,0 +1,40 @@
/** shadcn/ui 风格 ScrollArea(基于 @radix-ui/react-scroll-area) */
import { forwardRef } from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+94
View File
@@ -0,0 +1,94 @@
/** shadcn/ui 风格 Select(基于 @radix-ui/react-select) */
import { forwardRef } from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
}
+24
View File
@@ -0,0 +1,24 @@
/** shadcn/ui 风格 Separator(基于 @radix-ui/react-separator) */
import { forwardRef } from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+6
View File
@@ -0,0 +1,6 @@
/** sonner Toaster(登录页独立全屏,需自带) */
import { Toaster as SonnerToaster } from 'sonner'
export function Toaster() {
return <SonnerToaster position="top-center" richColors closeButton />
}
+22
View File
@@ -0,0 +1,22 @@
/** shadcn/ui 风格 Textarea */
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm ring-offset-background',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
)
Textarea.displayName = 'Textarea'