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:
@@ -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)} />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user