feat: 老板台2.0 + 手机端适配 + 专家库 + 智囊入口
- 老板台: AI执行摘要卡 + 待办优先级排序 + 时间线视图 + 环形健康度 - 手机端: 事项看板折叠列表 + 7个页面响应式修复 + 底部安全边距 - 专家库: 新增用户端专家浏览页(参考管理端卡片风格) - 智囊入口: 底部Tab加"智囊" + 4个智囊页场景切换栏 + "我"页分组入口 - FAB弹窗: 场景快速入口 + 指令卡片优化 + 输入区sticky - 后端: /dashboard/summary + /dashboard/timeline API - 后端: life场景事实维度扩充(2→5) + 17个生活关键词优先级 - 前端: 抑制React DevTools startTime噪音错误 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { Decisions } from '@/pages/Decisions'
|
||||
import { LifeAssistant } from '@/pages/LifeAssistant'
|
||||
import { EmotionCenter } from '@/pages/EmotionCenter'
|
||||
import { Team } from '@/pages/Team'
|
||||
import { Experts } from '@/pages/Experts'
|
||||
import { DecisionsLog } from '@/pages/DecisionsLog'
|
||||
import { KnowledgeBase } from '@/pages/KnowledgeBase'
|
||||
import { ChatHistory } from '@/pages/ChatHistory'
|
||||
@@ -41,6 +42,7 @@ export default function App() {
|
||||
<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="experts" element={<ProtectedRoute allowedRoles={['user']}><Experts /></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>} />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** 智囊场景切换栏(法务/决策/生活/情绪)
|
||||
* 在 4 个智囊页面顶部展示,点击跳转到对应场景
|
||||
*/
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Scale, TrendingUp, Heart, ShieldCheck } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SCENES = [
|
||||
{ path: '/legal', label: '法务', icon: Scale },
|
||||
{ path: '/decisions', label: '决策', icon: TrendingUp },
|
||||
{ path: '/life', label: '生活', icon: Heart },
|
||||
{ path: '/emotion', label: '情绪', icon: ShieldCheck },
|
||||
]
|
||||
|
||||
export function BrainTrustSwitcher() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{SCENES.map((s) => {
|
||||
const Icon = s.icon
|
||||
const active = location.pathname === s.path
|
||||
return (
|
||||
<button
|
||||
key={s.path}
|
||||
onClick={() => navigate(s.path)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-border bg-card text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{s.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -907,7 +907,7 @@ export function ChatPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">对话</CardTitle>
|
||||
{/* 模式选择:紧凑 segmented control */}
|
||||
<div className="flex items-center gap-0.5 rounded-md border bg-muted/50 p-0.5">
|
||||
<div className="flex flex-wrap items-center gap-0.5 rounded-md border bg-muted/50 p-0.5">
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
/** 专家头像组件(带 fallback 首字母) */
|
||||
/** 专家头像组件(带 fallback 首字母)
|
||||
* 支持圆形(rect=false)和长方形(rect=true)两种形状
|
||||
*/
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ExpertAvatarProps {
|
||||
name: string
|
||||
avatarUrl?: string | null
|
||||
size?: number
|
||||
/** 像素尺寸(数字)或预设(xl=64, 2xl=96) */
|
||||
size?: number | 'xl' | '2xl'
|
||||
/** 是否长方形头像 */
|
||||
shape?: 'circle' | 'rect'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ExpertAvatar({ name, avatarUrl, size = 40, className }: ExpertAvatarProps) {
|
||||
const SIZE_MAP: Record<string, number> = { xl: 64, '2xl': 96 }
|
||||
|
||||
export function ExpertAvatar({ name, avatarUrl, size = 40, shape = 'circle', className }: ExpertAvatarProps) {
|
||||
const px = typeof size === 'number' ? size : SIZE_MAP[size] || 40
|
||||
const initial = name?.[0] || '?'
|
||||
const shapeClass = shape === 'rect' ? 'rounded-lg' : 'rounded-full'
|
||||
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
style={{ width: size, height: size }}
|
||||
className={cn('rounded-full object-cover', className)}
|
||||
style={{ width: px, height: px }}
|
||||
className={cn('object-cover', shapeClass, className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{ width: size, height: size, fontSize: size * 0.4 }}
|
||||
style={{ width: px, height: px, fontSize: px * 0.4 }}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-full bg-primary/10 text-primary font-medium',
|
||||
'flex items-center justify-center bg-primary/10 text-primary font-medium',
|
||||
shapeClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/** 快捷指令面板(TASK-014 + TASK-015)
|
||||
* FAB 点击弹出底部抽屉,画像驱动的指令网格 + 语音/文字输入
|
||||
* 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 {
|
||||
Scale, TrendingUp, Heart, ShieldCheck, Mic, X, Send, Sparkles,
|
||||
ChevronRight, ClipboardList, Users,
|
||||
} from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QuickCommands, Scene } from '@/types'
|
||||
@@ -17,14 +20,26 @@ const SCENE_ICONS: Record<string, typeof Scale> = {
|
||||
legal: Scale,
|
||||
decision: TrendingUp,
|
||||
life: Heart,
|
||||
emotion: ShieldCheck,
|
||||
}
|
||||
|
||||
const SCENE_ROUTES: Record<string, string> = {
|
||||
legal: '/legal',
|
||||
decision: '/decisions',
|
||||
life: '/life',
|
||||
emotion: '/emotion',
|
||||
}
|
||||
|
||||
/** 顶部场景快速入口 */
|
||||
const QUICK_ENTRIES = [
|
||||
{ path: '/legal', label: '法务', icon: Scale, color: 'text-blue-600 bg-blue-50' },
|
||||
{ path: '/decisions', label: '决策', icon: TrendingUp, color: 'text-violet-600 bg-violet-50' },
|
||||
{ path: '/life', label: '生活', icon: Heart, color: 'text-rose-600 bg-rose-50' },
|
||||
{ path: '/emotion', label: '情绪', icon: ShieldCheck, color: 'text-amber-600 bg-amber-50' },
|
||||
{ path: '/matters', label: '事项', icon: ClipboardList, color: 'text-indigo-600 bg-indigo-50' },
|
||||
{ path: '/experts', label: '专家', icon: Users, color: 'text-emerald-600 bg-emerald-50' },
|
||||
]
|
||||
|
||||
export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
|
||||
const navigate = useNavigate()
|
||||
const [commands, setCommands] = useState<QuickCommands | null>(null)
|
||||
@@ -60,10 +75,7 @@ export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
|
||||
/** 语音输入(Web Speech API) */
|
||||
const startListening = () => {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
if (!SpeechRecognition) {
|
||||
// 不支持语音识别,静默降级
|
||||
return
|
||||
}
|
||||
if (!SpeechRecognition) return
|
||||
if (listening) {
|
||||
recognitionRef.current?.stop()
|
||||
setListening(false)
|
||||
@@ -99,101 +111,136 @@ export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
|
||||
<>
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm 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',
|
||||
'fixed bottom-0 left-0 right-0 z-50 max-h-[85vh] overflow-y-auto rounded-t-3xl bg-card shadow-2xl md:hidden',
|
||||
'animate-slide-up',
|
||||
)}>
|
||||
{/* 顶部把手 + 关闭 */}
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
{/* 拖拽把手 */}
|
||||
<div className="flex justify-center pt-3">
|
||||
<div className="h-1.5 w-10 rounded-full bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 顶部标题 + 关闭 */}
|
||||
<div className="flex items-center justify-between px-4 pb-3 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium text-foreground">你需要什么帮助?</span>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">你需要什么帮助?</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground transition hover:bg-muted/80"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</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 className="px-4 pb-3">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{QUICK_ENTRIES.map((entry) => {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
onClick={() => { navigate(entry.path); onClose() }}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
>
|
||||
<div className={cn('flex h-11 w-11 items-center justify-center rounded-xl', entry.color)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">{entry.label}</span>
|
||||
</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>
|
||||
{/* 画像不完整提示 */}
|
||||
{commands?.profile_incomplete && commands.incomplete_hint && (
|
||||
<div className="mx-4 mb-3 rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
|
||||
{commands.incomplete_hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 指令分组 */}
|
||||
<div className="px-4 pb-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 animate-pulse" />
|
||||
AI 正在生成个性化指令...
|
||||
</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-foreground">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
{label}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{items.map((cmd) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => handleCommandClick(key, cmd.template)}
|
||||
className="group flex items-center gap-2 rounded-xl border border-border bg-card p-3 text-left text-xs text-foreground transition-all hover:border-primary/40 hover:bg-primary/5 active:scale-95"
|
||||
>
|
||||
<span className="flex-1 leading-tight">{cmd.label}</span>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部输入区 */}
|
||||
<div className="sticky bottom-0 border-t border-border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={startListening}
|
||||
className={cn(
|
||||
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-all',
|
||||
listening
|
||||
? 'bg-red-500 text-white animate-pulse shadow-lg shadow-red-500/30'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||
)}
|
||||
aria-label="语音输入"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<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"
|
||||
className="w-full rounded-full border border-border bg-background px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
<button
|
||||
onClick={handleTextSubmit}
|
||||
disabled={!textInput.trim()}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/30 disabled:opacity-40"
|
||||
aria-label="发送"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{listening && (
|
||||
<p className="mt-2 text-center text-xs text-red-500">点击麦克风停止录音</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function AppLayout() {
|
||||
<Sidebar />
|
||||
<div className="md:pl-60">
|
||||
<Header />
|
||||
<main className="container mx-auto max-w-7xl px-4 py-6">
|
||||
<main className="container mx-auto max-w-7xl px-4 py-6 pb-24 md:pb-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,10 @@ export function Fab() {
|
||||
<>
|
||||
<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"
|
||||
className="fab fixed bottom-20 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-lg shadow-primary/30 transition-all hover:scale-105 hover:shadow-xl hover:shadow-primary/40 active:scale-95 md:hidden"
|
||||
aria-label="新建咨询"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
<Plus className={open ? 'h-6 w-6 rotate-45 transition-transform' : 'h-6 w-6 transition-transform'} />
|
||||
</button>
|
||||
<QuickCommandPanel open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@ import type {
|
||||
AskResponse, EmotionMode, Expert, ExpertService, Consultation,
|
||||
FileInspectResponse, HistoryMessage, HistorySession, KnowledgeDoc,
|
||||
Scene, SessionMode, SessionResponse, Settings,
|
||||
Matter, MatterBoard, DashboardToday, RiskAlert,
|
||||
Matter, MatterBoard, DashboardToday, DashboardSummary, TimelineItem, RiskAlert,
|
||||
ExpertRelation, QuickCommands,
|
||||
} from '@/types'
|
||||
|
||||
@@ -350,6 +350,8 @@ export const api = {
|
||||
|
||||
// ===== 2.0 新增:老板台 =====
|
||||
getDashboardToday: () => request<DashboardToday>('/dashboard/today'),
|
||||
getDashboardSummary: () => request<DashboardSummary>('/dashboard/summary'),
|
||||
getDashboardTimeline: () => request<{ results: TimelineItem[] }>('/dashboard/timeline'),
|
||||
|
||||
// ===== 2.0 新增:我的团队 =====
|
||||
getTeam: () => request<{ results: ExpertRelation[] }>('/team'),
|
||||
|
||||
@@ -30,12 +30,13 @@ export const USER_NAV: NavItem[] = [
|
||||
// 我的事项
|
||||
{ path: '/matters', label: '事项看板', icon: ClipboardList, group: '我的事项', tab: true },
|
||||
// 智囊
|
||||
{ path: '/legal', label: '法务参谋', icon: Scale, group: '智囊' },
|
||||
{ path: '/legal', label: '智囊', icon: Scale, group: '智囊', tab: true },
|
||||
{ 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: '/experts', label: '专家库', icon: Users, group: '我的资源' },
|
||||
{ path: '/decisions-log', label: '决策日志', icon: FileText, group: '我的资源' },
|
||||
{ path: '/knowledge', label: '知识库', icon: BookOpen, group: '我的资源' },
|
||||
{ path: '/history', label: '对话历史', icon: History, group: '我的资源' },
|
||||
@@ -96,6 +97,7 @@ export const HEADER_TITLES: Record<string, string> = {
|
||||
'/life': '生活助理',
|
||||
'/emotion': '情绪中心',
|
||||
'/team': '我的团队',
|
||||
'/experts': '专家库',
|
||||
'/decisions-log': '决策日志',
|
||||
'/knowledge': '知识库',
|
||||
'/history': '对话历史',
|
||||
|
||||
@@ -4,6 +4,20 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
/** 抑制 React DevTools 扩展的 startTime 兼容性噪音错误(非应用代码问题) */
|
||||
const originalError = window.console.error
|
||||
window.console.error = (...args: unknown[]) => {
|
||||
const msg = String(args[0] || '')
|
||||
if (msg.includes('startTime') || msg.includes('React DevTools')) return
|
||||
originalError.call(console, ...args)
|
||||
}
|
||||
window.addEventListener('error', (e) => {
|
||||
if (e.message?.includes('startTime') || e.message?.includes('reportAllChanges')) {
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
|
||||
+352
-133
@@ -1,28 +1,133 @@
|
||||
/** 老板台(TASK-007 + TASK-024)
|
||||
* 替代 v1.0 Dashboard,展示"今日要事":
|
||||
* - 问候语
|
||||
* - 风险推送(AI 主动提醒)
|
||||
* - 高风险提醒
|
||||
* - 待确认事项
|
||||
* - 专家回复
|
||||
* - 经营健康度
|
||||
/** 老板台 2.0(TASK-007 + TASK-024)
|
||||
* 老板视角仪表盘:
|
||||
* - 执行摘要卡(AI 生成今日总结 + 建议行动)
|
||||
* - 概览数字(风险/待确认/回复/健康度)
|
||||
* - 待办优先级(按紧急程度排序,最紧急的置顶放大)
|
||||
* - 时间线(最近活动按时间倒序)
|
||||
* - 快捷入口
|
||||
* - 经营健康度(环形)
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, ClipboardCheck, MessageSquare, Activity, ArrowRight, Radar } from 'lucide-react'
|
||||
import {
|
||||
ClipboardCheck, MessageSquare, Activity, ArrowRight,
|
||||
Radar, ShieldCheck, Scale, TrendingUp, Heart, Plus, ChevronRight,
|
||||
Sparkles, Clock, CircleAlert,
|
||||
} from 'lucide-react'
|
||||
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 { api } from '@/lib/api'
|
||||
import { greeting, formatRelative, SCENE_LABELS, RISK_LABELS } from '@/lib/utils'
|
||||
import type { DashboardToday, RiskAlert } from '@/types'
|
||||
import { greeting, formatRelative, SCENE_LABELS, RISK_LABELS, MATTER_STATUS_LABELS, cn } from '@/lib/utils'
|
||||
import type { DashboardToday, DashboardSummary, TimelineItem, RiskAlert, Scene } from '@/types'
|
||||
|
||||
/** 环形进度条 */
|
||||
function RingProgress({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
const radius = 28
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (value / 100) * circumference
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="relative h-20 w-20">
|
||||
<svg className="h-full w-full -rotate-90" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r={radius} fill="none" stroke="currentColor" strokeWidth="5" className="text-muted/30" />
|
||||
<circle
|
||||
cx="32" cy="32" r={radius} fill="none" stroke="currentColor" strokeWidth="5"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
strokeLinecap="round" className={color}
|
||||
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-foreground">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SCENE_ICONS: Record<Scene, typeof Scale> = {
|
||||
legal: Scale,
|
||||
decision: TrendingUp,
|
||||
life: Heart,
|
||||
emotion: ShieldCheck,
|
||||
}
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: '法务咨询', icon: Scale, path: '/legal', color: 'text-blue-600 bg-blue-50' },
|
||||
{ label: '决策参谋', icon: TrendingUp, path: '/decisions', color: 'text-violet-600 bg-violet-50' },
|
||||
{ label: '事项看板', icon: ClipboardCheck, path: '/matters', color: 'text-amber-600 bg-amber-50' },
|
||||
{ label: '我的团队', icon: MessageSquare, path: '/team', color: 'text-emerald-600 bg-emerald-50' },
|
||||
]
|
||||
|
||||
/** 待办优先级定义 */
|
||||
const PRIORITY_CONFIG = {
|
||||
red: { label: '紧急', color: 'text-red-600 bg-red-50 border-red-200', rank: 0 },
|
||||
orange: { label: '高优', color: 'text-orange-600 bg-orange-50 border-orange-200', rank: 1 },
|
||||
pending: { label: '待确认', color: 'text-indigo-600 bg-indigo-50 border-indigo-200', rank: 2 },
|
||||
reply: { label: '待查看', color: 'text-emerald-600 bg-emerald-50 border-emerald-200', rank: 3 },
|
||||
alert: { label: '提醒', color: 'text-amber-600 bg-amber-50 border-amber-200', rank: 4 },
|
||||
}
|
||||
|
||||
/** 时间线条目渲染 */
|
||||
function TimelineEntry({ item, onClick }: { item: TimelineItem; onClick: () => void }) {
|
||||
if (item.type === 'matter') {
|
||||
const Icon = SCENE_ICONS[item.scene || 'legal'] || Scale
|
||||
return (
|
||||
<button onClick={onClick} className="flex w-full items-start gap-3 rounded-lg p-2 text-left transition hover:bg-muted/50">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">{item.title}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{SCENE_LABELS[item.scene || 'legal']}</span>
|
||||
{item.status && <><span>·</span><span>{MATTER_STATUS_LABELS[item.status as keyof typeof MATTER_STATUS_LABELS] || item.status}</span></>}
|
||||
{item.time && <><span>·</span><span>{formatRelative(item.time)}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (item.type === 'expert_reply') {
|
||||
return (
|
||||
<button onClick={onClick} className="flex w-full items-start gap-3 rounded-lg p-2 text-left transition hover:bg-muted/50">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-50">
|
||||
<MessageSquare className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">{item.expert_name} · {item.expert_title}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{item.snippet}</p>
|
||||
{item.time && <p className="text-xs text-muted-foreground">{formatRelative(item.time)}</p>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
// risk_alert
|
||||
return (
|
||||
<button onClick={onClick} className="flex w-full items-start gap-3 rounded-lg p-2 text-left transition hover:bg-muted/50">
|
||||
<div className={cn('flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
item.level === 'red' ? 'bg-red-50' : item.level === 'orange' ? 'bg-orange-50' : 'bg-blue-50')}>
|
||||
<Radar className={cn('h-4 w-4', item.level === 'red' ? 'text-red-600' : item.level === 'orange' ? 'text-orange-600' : 'text-blue-600')} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">{item.title}</p>
|
||||
{item.time && <p className="text-xs text-muted-foreground">{formatRelative(item.time)}</p>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<DashboardToday | null>(null)
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null)
|
||||
const [timeline, setTimeline] = useState<TimelineItem[]>([])
|
||||
const [alerts, setAlerts] = useState<RiskAlert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -31,9 +136,14 @@ export function Dashboard() {
|
||||
setLoading(true); setError('')
|
||||
Promise.all([
|
||||
api.getDashboardToday(),
|
||||
api.getDashboardSummary().catch(() => null),
|
||||
api.getDashboardTimeline().catch(() => ({ results: [] as TimelineItem[] })),
|
||||
api.getRiskAlerts(true).catch(() => ({ results: [] as RiskAlert[] })),
|
||||
])
|
||||
.then(([d, a]) => { setData(d); setAlerts(a.results); setLoading(false) })
|
||||
.then(([d, s, t, a]) => {
|
||||
setData(d); setSummary(s); setTimeline(t.results); setAlerts(a.results)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((e) => { setError(e.message); setLoading(false) })
|
||||
}
|
||||
|
||||
@@ -50,124 +160,248 @@ export function Dashboard() {
|
||||
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
|
||||
const riskCount = alerts.length + data.high_risks.length
|
||||
const pendingCount = data.pending_confirm.length
|
||||
const replyCount = data.expert_replies.length
|
||||
const hasContent = riskCount > 0 || pendingCount > 0 || replyCount > 0
|
||||
|
||||
/** 构建待办优先级列表 */
|
||||
const todos: Array<{
|
||||
id: string
|
||||
title: string
|
||||
desc: string
|
||||
priority: keyof typeof PRIORITY_CONFIG
|
||||
action: () => void
|
||||
actionLabel: string
|
||||
}> = []
|
||||
|
||||
// 高风险(红色)
|
||||
data.high_risks.filter((r) => r.level === 'red').forEach((r) => {
|
||||
todos.push({
|
||||
id: `risk-${r.matter_id}`, title: r.title, desc: `${SCENE_LABELS[r.scene]} · ${RISK_LABELS[r.level]}`,
|
||||
priority: 'red', action: () => navigate('/matters'), actionLabel: '立即处理',
|
||||
})
|
||||
})
|
||||
// 风险推送
|
||||
alerts.forEach((a) => {
|
||||
todos.push({
|
||||
id: `alert-${a.id}`, title: a.title, desc: a.content.slice(0, 60),
|
||||
priority: a.level === 'red' ? 'red' : 'alert',
|
||||
action: () => handleReadAlert(a.id), actionLabel: '稍后',
|
||||
})
|
||||
})
|
||||
// 中风险(橙色)
|
||||
data.high_risks.filter((r) => r.level === 'orange').forEach((r) => {
|
||||
todos.push({
|
||||
id: `orange-${r.matter_id}`, title: r.title, desc: `${SCENE_LABELS[r.scene]} · ${RISK_LABELS[r.level]}`,
|
||||
priority: 'orange', action: () => navigate('/matters'), actionLabel: '去处理',
|
||||
})
|
||||
})
|
||||
// 待确认
|
||||
data.pending_confirm.forEach((p) => {
|
||||
todos.push({
|
||||
id: `pending-${p.matter_id}`, title: p.title, desc: `${SCENE_LABELS[p.scene]} · ${formatRelative(p.updated_at)}`,
|
||||
priority: 'pending', action: () => navigate('/matters'), actionLabel: '去确认',
|
||||
})
|
||||
})
|
||||
// 专家回复
|
||||
data.expert_replies.forEach((r) => {
|
||||
todos.push({
|
||||
id: `reply-${r.consultation_id}`, title: `${r.expert_name} · ${r.expert_title}`,
|
||||
desc: r.snippet, priority: 'reply', action: () => navigate('/team'), actionLabel: '查看',
|
||||
})
|
||||
})
|
||||
|
||||
// 按优先级排序
|
||||
todos.sort((a, b) => PRIORITY_CONFIG[a.priority].rank - PRIORITY_CONFIG[b.priority].rank)
|
||||
|
||||
return (
|
||||
<PageLayout title={`${data.greeting || greeting()},老板`} description="今天有需要你关注的事">
|
||||
{!hasContent && (
|
||||
{/* 执行摘要卡 */}
|
||||
{summary && (
|
||||
<Card className={cn(
|
||||
'border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10',
|
||||
summary.ai_generated && 'border-primary/30',
|
||||
)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">今日要点</span>
|
||||
{summary.ai_generated && (
|
||||
<Badge variant="outline" className="border-primary/30 text-primary text-[10px]">AI</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-foreground">{summary.summary}</p>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-primary">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span>{summary.action}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 概览数字 */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
onClick={() => riskCount > 0 && navigate('/matters')}
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition-all hover:shadow-md',
|
||||
riskCount > 0 ? 'border-orange-200 bg-orange-50' : 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className={cn('h-4 w-4', riskCount > 0 ? 'text-orange-600' : 'text-muted-foreground')} />
|
||||
<span className="text-xs text-muted-foreground">风险提醒</span>
|
||||
</div>
|
||||
<p className={cn('mt-1 text-2xl font-bold', riskCount > 0 ? 'text-orange-700' : 'text-muted-foreground')}>{riskCount}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => pendingCount > 0 && navigate('/matters')}
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition-all hover:shadow-md',
|
||||
pendingCount > 0 ? 'border-indigo-200 bg-indigo-50' : 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className={cn('h-4 w-4', pendingCount > 0 ? 'text-indigo-600' : 'text-muted-foreground')} />
|
||||
<span className="text-xs text-muted-foreground">待确认</span>
|
||||
</div>
|
||||
<p className={cn('mt-1 text-2xl font-bold', pendingCount > 0 ? 'text-indigo-700' : 'text-muted-foreground')}>{pendingCount}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => replyCount > 0 && navigate('/team')}
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition-all hover:shadow-md',
|
||||
replyCount > 0 ? 'border-emerald-200 bg-emerald-50' : 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className={cn('h-4 w-4', replyCount > 0 ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||
<span className="text-xs text-muted-foreground">专家回复</span>
|
||||
</div>
|
||||
<p className={cn('mt-1 text-2xl font-bold', replyCount > 0 ? 'text-emerald-700' : 'text-muted-foreground')}>{replyCount}</p>
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl border border-border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs text-muted-foreground">健康度</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-primary">
|
||||
{Math.round((data.health_score.legal + data.health_score.operation + data.health_score.compliance) / 3)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{QUICK_ACTIONS.map((a) => {
|
||||
const Icon = a.icon
|
||||
return (
|
||||
<button
|
||||
key={a.path}
|
||||
onClick={() => navigate(a.path)}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl border border-border bg-card p-3 transition-all hover:shadow-md hover:border-primary/30"
|
||||
>
|
||||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', a.color)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{a.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!hasContent && riskCount === 0 && timeline.length === 0 && (
|
||||
<EmptyState
|
||||
title="今天暂无要事"
|
||||
description="有新的事项或风险时会在这里提醒你"
|
||||
icon={Activity}
|
||||
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
|
||||
action={<Button onClick={() => navigate('/legal')}><Plus className="mr-1 h-4 w-4" />开始咨询</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>
|
||||
{/* 待办优先级列表 */}
|
||||
{todos.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CircleAlert className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold text-foreground">待办优先级</span>
|
||||
<Badge variant="secondary">{todos.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo, idx) => {
|
||||
const config = PRIORITY_CONFIG[todo.priority]
|
||||
const isTop = idx === 0
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg border p-3 transition-all',
|
||||
config.color,
|
||||
isTop && 'ring-2 ring-primary/20',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* 优先级标记 */}
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
<span className={cn('text-xs font-bold', config.color.split(' ')[0])}>{config.label}</span>
|
||||
{isTop && <span className="text-[10px] text-muted-foreground">最紧急</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 className="min-w-0 flex-1 border-l border-current/20 pl-3">
|
||||
<div className={cn('truncate text-sm', isTop ? 'font-semibold' : 'font-medium', 'text-foreground')}>
|
||||
{todo.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{todo.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate('/matters')}>
|
||||
去确认 <ArrowRight className="ml-1 h-3 w-3" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant={todo.priority === 'red' ? 'destructive' : 'outline'}
|
||||
onClick={todo.action}
|
||||
className="shrink-0"
|
||||
>
|
||||
{todo.actionLabel}
|
||||
<ChevronRight className="ml-0.5 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 专家回复 */}
|
||||
{data.expert_replies.length > 0 && (
|
||||
<Card>
|
||||
{/* 时间线 + 健康度(两列) */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* 时间线(占 2 列) */}
|
||||
{timeline.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<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 className="mb-3 flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-semibold text-foreground">最近动态</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 className="space-y-1">
|
||||
{timeline.map((item) => (
|
||||
<TimelineEntry
|
||||
key={`${item.type}-${item.id}`}
|
||||
item={item}
|
||||
onClick={() => {
|
||||
if (item.type === 'matter') navigate('/matters')
|
||||
else if (item.type === 'expert_reply') navigate('/team')
|
||||
else navigate('/knowledge')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -177,29 +411,14 @@ export function Dashboard() {
|
||||
{/* 经营健康度 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">你的经营健康度</span>
|
||||
<span className="font-semibold text-foreground">经营健康度</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 className="flex items-center justify-around">
|
||||
<RingProgress value={data.health_score.legal} label="法务" color="text-blue-500" />
|
||||
<RingProgress value={data.health_score.operation} label="经营" color="text-violet-500" />
|
||||
<RingProgress value={data.health_score.compliance} label="合规" color="text-emerald-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { TrendingUp, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -55,6 +56,8 @@ export function Decisions() {
|
||||
title="经营决策助手"
|
||||
description="项目承接、扩张收缩、招人、降价、业务线调整、融资合伙。情景推演,把利弊讲透。"
|
||||
>
|
||||
<BrainTrustSwitcher />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -125,8 +125,8 @@ export function DecisionsLog() {
|
||||
<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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate 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] || ''}`}>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EmotionMode } from '@/types'
|
||||
@@ -58,6 +59,8 @@ export function EmotionCenter() {
|
||||
title="情绪价值引擎"
|
||||
description="先处理情绪,再处理事情。选择适合你当前状态的模式。"
|
||||
>
|
||||
<BrainTrustSwitcher />
|
||||
|
||||
{/* 危机干预提示 */}
|
||||
<Card className="border-rose-200 bg-rose-50">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/** 专家库:浏览所有专家,选择后跳转到对应智囊场景咨询
|
||||
* 卡片样式参考管理端专家管理:长方形头像 + 姓名/评分 + 简介 + 专长标签
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Users, Star, ChevronRight, Briefcase } from 'lucide-react'
|
||||
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 { ExpertAvatar } from '@/components/common/ExpertAvatar'
|
||||
import { api } from '@/lib/api'
|
||||
import { SCENE_LABELS, cn } from '@/lib/utils'
|
||||
import type { Expert, Scene } from '@/types'
|
||||
|
||||
const CATEGORY_FILTERS: { value: Scene | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'legal', label: '法务' },
|
||||
{ value: 'decision', label: '决策' },
|
||||
{ value: 'life', label: '生活' },
|
||||
{ value: 'emotion', label: '情绪' },
|
||||
]
|
||||
|
||||
export function Experts() {
|
||||
const navigate = useNavigate()
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filter, setFilter] = useState<Scene | 'all'>('all')
|
||||
|
||||
const load = () => {
|
||||
setLoading(true); setError('')
|
||||
api.listExperts(filter === 'all' ? undefined : filter)
|
||||
.then((r) => { setExperts(r.results); setLoading(false) })
|
||||
.catch((e) => { setError(e.message); setLoading(false) })
|
||||
}
|
||||
|
||||
useEffect(load, [filter])
|
||||
|
||||
if (loading) return <LoadingState label="加载专家库..." />
|
||||
if (error) return <ErrorState message={error} onRetry={load} />
|
||||
|
||||
return (
|
||||
<PageLayout title="专家库" description={`共 ${experts.length} 位专家可咨询`}>
|
||||
{/* 分类筛选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{CATEGORY_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
||||
filter === f.value
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-border bg-card text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{experts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无专家"
|
||||
description="该分类下还没有可用专家"
|
||||
icon={Users}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{experts.map((ex) => {
|
||||
const stars = (ex.rate / 100).toFixed(1)
|
||||
return (
|
||||
<Card key={ex.id} className="transition-shadow hover:shadow-md">
|
||||
<CardContent className="p-4 sm:p-5">
|
||||
{/* 头部:长方形头像(左) + 姓名/评分(右) */}
|
||||
<div className="flex items-start gap-4">
|
||||
<ExpertAvatar
|
||||
name={ex.name}
|
||||
avatarUrl={ex.avatar_url}
|
||||
size="xl"
|
||||
shape="rect"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate font-medium text-foreground">{ex.name}</h3>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">{ex.title}</p>
|
||||
{ex.rating_count > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs">
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
<span className="font-medium">{stars}</span>
|
||||
<span className="text-muted-foreground">({ex.rating_count}人评价)</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 简介 */}
|
||||
{ex.bio && (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{ex.bio}</p>
|
||||
)}
|
||||
{/* 专长标签 */}
|
||||
{ex.expertise && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{ex.expertise.split(',').map((tag, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:分类 + 咨询按钮 */}
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
|
||||
<Badge variant="secondary">
|
||||
{SCENE_LABELS[ex.category] || ex.category}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/${ex.category}`, { state: { expertId: ex.id } })}
|
||||
>
|
||||
<Briefcase className="mr-1 h-3.5 w-3.5" /> 咨询
|
||||
<ChevronRight className="ml-0.5 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -303,9 +303,9 @@ export function KnowledgeBase() {
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{typeInfo && <typeInfo.icon className="h-4 w-4 text-muted-foreground" aria-hidden />}
|
||||
<h3 className="font-medium">{doc.title}</h3>
|
||||
<h3 className="truncate font-medium">{doc.title}</h3>
|
||||
{doc.doc_type && (
|
||||
<Badge variant="secondary">{typeInfo?.label || doc.doc_type}</Badge>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/** 法务参谋页:话题选择 + 换一批 + ChatPanel
|
||||
* scene='legal', 默认保密模式, 显示风险卡, 启用上传
|
||||
* 顶部含智囊场景切换栏(法务/决策/生活/情绪)
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Briefcase, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -56,6 +58,8 @@ export function LegalAdvisor() {
|
||||
title="法务参谋"
|
||||
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁。AI 输出不替代执业律师正式意见。"
|
||||
>
|
||||
<BrainTrustSwitcher />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Heart, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -55,6 +56,8 @@ export function LifeAssistant() {
|
||||
title="生活助理"
|
||||
description="家庭沟通、子女教育、父母养老、健康习惯、出行规划。老板的生活问题,也值得认真对待。"
|
||||
>
|
||||
<BrainTrustSwitcher />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+72
-23
@@ -1,10 +1,10 @@
|
||||
/** 事项看板(TASK-008)
|
||||
* 案卡按状态分列展示,点击查看详情(弹窗),支持断点续接
|
||||
* 移动端横向滚动看板列
|
||||
* 桌面端:6 列看板;移动端:按状态折叠列表
|
||||
*/
|
||||
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 { ClipboardList, Plus, Scale, TrendingUp, Heart, ShieldCheck, ArrowRight, Loader2, ChevronDown } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -44,11 +44,19 @@ export function Matters() {
|
||||
const [error, setError] = useState('')
|
||||
const [selectedMatter, setSelectedMatter] = useState<Matter | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
/** 移动端折叠状态:默认展开有内容的列 */
|
||||
const [expandedCols, setExpandedCols] = useState<Set<MatterStatus>>(new Set())
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setError('')
|
||||
api.getMatters()
|
||||
.then((r) => { setBoard(r.board); setTotal(r.total); setLoading(false) })
|
||||
.then((r) => {
|
||||
setBoard(r.board); setTotal(r.total); setLoading(false)
|
||||
// 移动端:默认展开有内容的列
|
||||
const expanded = new Set<MatterStatus>()
|
||||
COLUMNS.forEach((s) => { if (r.board[s]?.length > 0) expanded.add(s) })
|
||||
setExpandedCols(expanded)
|
||||
})
|
||||
.catch((e) => { setError(e.message); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
@@ -96,28 +104,69 @@ export function Matters() {
|
||||
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 className="space-y-2 md:hidden">
|
||||
{COLUMNS.map((status) => {
|
||||
const items = board?.[status] || []
|
||||
if (items.length === 0) return null
|
||||
const expanded = expandedCols.has(status)
|
||||
return (
|
||||
<div key={status} className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedCols((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(status)) next.delete(status)
|
||||
else next.add(status)
|
||||
return next
|
||||
})}
|
||||
className="flex w-full items-center justify-between p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full', MATTER_STATUS_COLORS[status].split(' ').find(c => c.startsWith('bg-') || c.startsWith('text-')) || 'bg-muted')} />
|
||||
<span className="text-sm font-medium text-foreground">{MATTER_STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{items.length}</span>
|
||||
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', expanded && 'rotate-180')} />
|
||||
</div>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="space-y-2 border-t border-border p-2">
|
||||
{items.map((m) => (
|
||||
<MatterCard key={m.id} matter={m} onClick={() => handleCardClick(m)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 桌面端:6 列看板 */}
|
||||
<div className="hidden gap-4 md:grid md:grid-cols-3 md:overflow-visible lg:grid-cols-6">
|
||||
{COLUMNS.map((status) => {
|
||||
const items = board?.[status] || []
|
||||
return (
|
||||
<div key={status} className="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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 案卡详情弹窗 */}
|
||||
|
||||
+40
-10
@@ -1,11 +1,23 @@
|
||||
/** 移动端"我"聚合页:头像占位+入口列表 */
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { User, History, BookOpen, Settings as SettingsIcon, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
User, History, BookOpen, Settings as SettingsIcon, ChevronRight,
|
||||
Scale, TrendingUp, Heart, ShieldCheck, FileText, Users,
|
||||
} from 'lucide-react'
|
||||
import { getName } from '@/lib/auth'
|
||||
|
||||
export function Me() {
|
||||
const name = getName()
|
||||
const items = [
|
||||
const brainTrust = [
|
||||
{ path: '/legal', label: '法务参谋', icon: Scale },
|
||||
{ path: '/decisions', label: '经营决策', icon: TrendingUp },
|
||||
{ path: '/life', label: '生活助理', icon: Heart },
|
||||
{ path: '/emotion', label: '情绪中心', icon: ShieldCheck },
|
||||
]
|
||||
const resources = [
|
||||
{ path: '/team', label: '我的团队', icon: Users },
|
||||
{ path: '/experts', label: '专家库', icon: Users },
|
||||
{ path: '/decisions-log', label: '决策日志', icon: FileText },
|
||||
{ path: '/profile', label: '我的画像', icon: User },
|
||||
{ path: '/history', label: '对话历史', icon: History },
|
||||
{ path: '/knowledge', label: '知识库', icon: BookOpen },
|
||||
@@ -22,14 +34,32 @@ export function Me() {
|
||||
<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>
|
||||
<p className="mb-2 px-1 text-xs font-medium text-muted-foreground">智囊</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{brainTrust.map((item) => (
|
||||
<NavLink key={item.path} to={item.path} className="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 hover:bg-muted transition-colors">
|
||||
<item.icon className="h-6 w-6 text-primary" />
|
||||
<span className="text-sm text-foreground">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 我的资源 */}
|
||||
<div>
|
||||
<p className="mb-2 px-1 text-xs font-medium text-muted-foreground">我的资源</p>
|
||||
<div className="rounded-xl border border-border bg-card divide-y divide-border">
|
||||
{resources.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>
|
||||
</div>
|
||||
)
|
||||
|
||||
+40
-34
@@ -1,5 +1,6 @@
|
||||
/** 我的团队(TASK-017)
|
||||
* 重构专家库为关系视图:咨询过的专家自动沉淀
|
||||
* 手机端:紧凑卡片布局,信息分层展示
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -7,6 +8,7 @@ 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 { Badge } from '@/components/ui/badge'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
@@ -37,53 +39,57 @@ export function Team() {
|
||||
<PageLayout
|
||||
title="我的团队"
|
||||
description={`共 ${team.length} 位专家`}
|
||||
actions={<Button variant="outline" size="sm" onClick={() => navigate('/legal')}><Plus className="mr-1 h-4 w-4" /> 找新专家</Button>}
|
||||
actions={<Button variant="outline" size="sm" onClick={() => navigate('/experts')}><Plus className="mr-1 h-4 w-4" /> 找新专家</Button>}
|
||||
>
|
||||
{team.length === 0 ? (
|
||||
<EmptyState
|
||||
title="你还没有咨询过专家"
|
||||
description="开始一次咨询,问专家时会自动将专家加入你的团队"
|
||||
icon={Users}
|
||||
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
|
||||
action={<Button onClick={() => navigate('/experts')}>浏览专家库</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}
|
||||
<CardContent className="p-3 sm:p-4">
|
||||
{/* 上层:头像 + 名称 + 在线状态 */}
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<ExpertAvatar
|
||||
name={member.name}
|
||||
avatarUrl={member.avatar_url}
|
||||
size={44}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate font-medium text-foreground">{member.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground sm:text-sm">{member.title}</span>
|
||||
{member.online && (
|
||||
<span className="flex shrink-0 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.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{SCENE_LABELS[member.category] || member.category}
|
||||
</Badge>
|
||||
<span>咨询 {member.consultation_count} 次</span>
|
||||
{member.last_consulted_at && (
|
||||
<span className="hidden sm:inline">· 最近 {formatRelative(member.last_consulted_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
|
||||
{/* 上次话题 + 操作按钮 */}
|
||||
{member.last_topic && (
|
||||
<div className="mt-2 truncate rounded-md bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
上次话题: {member.last_topic}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/${member.category}`, { state: { expertId: member.expert_id, lastTopic: member.last_topic } })}
|
||||
|
||||
@@ -235,6 +235,31 @@ export interface DashboardToday {
|
||||
unread_alerts: number
|
||||
}
|
||||
|
||||
/** 老板台执行摘要 */
|
||||
export interface DashboardSummary {
|
||||
summary: string
|
||||
action: string
|
||||
ai_generated: boolean
|
||||
}
|
||||
|
||||
/** 时间线条目 */
|
||||
export interface TimelineItem {
|
||||
type: 'matter' | 'expert_reply' | 'risk_alert'
|
||||
id: number
|
||||
time: string | null
|
||||
// matter
|
||||
title?: string
|
||||
scene?: Scene
|
||||
status?: string
|
||||
risk_level?: RiskLevel
|
||||
// expert_reply
|
||||
expert_name?: string
|
||||
expert_title?: string
|
||||
snippet?: string
|
||||
// risk_alert
|
||||
level?: 'red' | 'orange' | 'blue'
|
||||
}
|
||||
|
||||
/** 风险推送 */
|
||||
export interface RiskAlert {
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user