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
@@ -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>
)
}
+1 -1
View File
@@ -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}
+18 -7
View File
@@ -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,
)}
>
+126 -79
View File
@@ -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>
</>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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)} />
</>