feat: 老板台2.0 + 手机端适配 + 专家库 + 智囊入口
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 老板台: 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:
selfrelease
2026-08-31 14:11:09 +08:00
co-authored by Devin
parent 4de36547cd
commit dd01d94ade
24 changed files with 1088 additions and 300 deletions
+352 -133
View File
@@ -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>
+3
View File
@@ -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">
+2 -2
View File
@@ -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] || ''}`}>
+3
View File
@@ -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">
+136
View File
@@ -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>
)
}
+2 -2
View File
@@ -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>
)}
+4
View File
@@ -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">
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 } })}