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:
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'
|
||||
Reference in New Issue
Block a user