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:
@@ -2410,6 +2410,172 @@ def dashboard_today(authorization: str | None = Header(default=None)):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard/summary")
|
||||||
|
def dashboard_summary(authorization: str | None = Header(default=None)):
|
||||||
|
"""老板台执行摘要:AI 用一句话总结当前状态和建议行动。
|
||||||
|
降级策略:Kimi 不可用时返回规则摘要。
|
||||||
|
"""
|
||||||
|
phone = get_current_phone(authorization)
|
||||||
|
if not phone:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
conn = _matter_dsn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, title, risk_level, scene, status FROM matters "
|
||||||
|
"WHERE user_phone = %s AND status != 'closed' ORDER BY updated_at DESC LIMIT 10",
|
||||||
|
(phone,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
open_matters = [{"id": r[0], "title": r[1], "risk_level": r[2], "scene": r[3], "status": r[4]} for r in rows]
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM matters WHERE user_phone = %s AND status = 'pending_confirm'",
|
||||||
|
(phone,),
|
||||||
|
)
|
||||||
|
pending_count = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM expert_consultations WHERE user_phone = %s AND status = 'replied'",
|
||||||
|
(phone,),
|
||||||
|
)
|
||||||
|
reply_count = cur.fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
red_count = sum(1 for m in open_matters if m["risk_level"] == "red")
|
||||||
|
orange_count = sum(1 for m in open_matters if m["risk_level"] == "orange")
|
||||||
|
|
||||||
|
# 规则摘要(兜底)
|
||||||
|
parts = []
|
||||||
|
if red_count > 0:
|
||||||
|
parts.append(f"有 {red_count} 项高风险事项需立即处理")
|
||||||
|
if orange_count > 0:
|
||||||
|
parts.append(f"{orange_count} 项中风险事项需关注")
|
||||||
|
if pending_count > 0:
|
||||||
|
parts.append(f"{pending_count} 个事项待你确认")
|
||||||
|
if reply_count > 0:
|
||||||
|
parts.append(f"{reply_count} 条专家回复待查看")
|
||||||
|
if not parts:
|
||||||
|
rule_summary = "当前无待办事项,经营状况良好。可主动发起咨询或查看经营健康度。"
|
||||||
|
rule_action = "浏览智囊或查看事项看板"
|
||||||
|
else:
|
||||||
|
rule_summary = "、".join(parts) + "。"
|
||||||
|
if red_count > 0:
|
||||||
|
rule_action = "建议优先处理高风险事项"
|
||||||
|
elif pending_count > 0:
|
||||||
|
rule_action = "建议先确认待办事项"
|
||||||
|
elif reply_count > 0:
|
||||||
|
rule_action = "建议查看专家回复"
|
||||||
|
else:
|
||||||
|
rule_action = "建议关注中风险事项"
|
||||||
|
|
||||||
|
# 尝试 AI 生成更自然的摘要
|
||||||
|
try:
|
||||||
|
if kimi and open_matters:
|
||||||
|
matter_desc = "\n".join(
|
||||||
|
f"- {m['title']}(风险:{m['risk_level']},场景:{m['scene']},状态:{m['status']})"
|
||||||
|
for m in open_matters[:5]
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
f"你是中小企业老板的AI助手。请根据以下数据,用一句话(不超过50字)总结当前经营状态,"
|
||||||
|
f"并给出一个最优先的行动建议(不超过20字)。\n\n"
|
||||||
|
f"待办事项:\n{matter_desc}\n"
|
||||||
|
f"待确认:{pending_count}个,专家回复:{reply_count}条\n\n"
|
||||||
|
f"返回 JSON 格式:{{\"summary\": \"...\", \"action\": \"...\"}}"
|
||||||
|
)
|
||||||
|
answer = kimi.chat(
|
||||||
|
[{"role": "system", "content": "你是经营顾问助手,只返回JSON。"},
|
||||||
|
{"role": "user", "content": prompt}],
|
||||||
|
temperature=0.3, timeout=15,
|
||||||
|
)
|
||||||
|
answer = answer.strip()
|
||||||
|
if answer.startswith("```"):
|
||||||
|
answer = answer.split("\n", 1)[1] if "\n" in answer else answer[3:]
|
||||||
|
if answer.endswith("```"):
|
||||||
|
answer = answer[:-3]
|
||||||
|
answer = answer.replace("\u201c", '"').replace("\u201d", '"').strip()
|
||||||
|
start = answer.find("{")
|
||||||
|
end = answer.rfind("}")
|
||||||
|
if start != -1 and end != -1:
|
||||||
|
import json as _json
|
||||||
|
parsed = _json.loads(answer[start:end + 1])
|
||||||
|
return {
|
||||||
|
"summary": parsed.get("summary", rule_summary),
|
||||||
|
"action": parsed.get("action", rule_action),
|
||||||
|
"ai_generated": True,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"summary": rule_summary, "action": rule_action, "ai_generated": False}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard/timeline")
|
||||||
|
def dashboard_timeline(authorization: str | None = Header(default=None)):
|
||||||
|
"""老板台时间线:按时间倒序聚合最近活动(案卡变更、专家回复、风险推送)。"""
|
||||||
|
phone = get_current_phone(authorization)
|
||||||
|
if not phone:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
conn = _matter_dsn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
items = []
|
||||||
|
# 案卡最近更新
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, title, scene, status, risk_level, updated_at "
|
||||||
|
"FROM matters WHERE user_phone = %s ORDER BY updated_at DESC LIMIT 8",
|
||||||
|
(phone,),
|
||||||
|
)
|
||||||
|
for r in cur.fetchall():
|
||||||
|
items.append({
|
||||||
|
"type": "matter",
|
||||||
|
"id": r[0],
|
||||||
|
"title": r[1],
|
||||||
|
"scene": r[2],
|
||||||
|
"status": r[3],
|
||||||
|
"risk_level": r[4],
|
||||||
|
"time": r[5].isoformat() if r[5] else None,
|
||||||
|
})
|
||||||
|
# 专家回复
|
||||||
|
cur.execute(
|
||||||
|
"SELECT c.id, e.name, e.title, c.reply, c.replied_at "
|
||||||
|
"FROM expert_consultations c JOIN experts e ON c.expert_id = e.id "
|
||||||
|
"WHERE c.user_phone = %s AND c.status = 'replied' "
|
||||||
|
"ORDER BY c.replied_at DESC LIMIT 5",
|
||||||
|
(phone,),
|
||||||
|
)
|
||||||
|
for r in cur.fetchall():
|
||||||
|
items.append({
|
||||||
|
"type": "expert_reply",
|
||||||
|
"id": r[0],
|
||||||
|
"expert_name": r[1],
|
||||||
|
"expert_title": r[2],
|
||||||
|
"snippet": (r[3] or "")[:60],
|
||||||
|
"time": r[4].isoformat() if r[4] else None,
|
||||||
|
})
|
||||||
|
# 风险推送
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, title, level, created_at FROM risk_alerts "
|
||||||
|
"WHERE user_phone = %s ORDER BY created_at DESC LIMIT 5",
|
||||||
|
(phone,),
|
||||||
|
)
|
||||||
|
for r in cur.fetchall():
|
||||||
|
items.append({
|
||||||
|
"type": "risk_alert",
|
||||||
|
"id": r[0],
|
||||||
|
"title": r[1],
|
||||||
|
"level": r[2],
|
||||||
|
"time": r[3].isoformat() if r[3] else None,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass # risk_alerts 表可能不存在
|
||||||
|
# 按时间倒序
|
||||||
|
items.sort(key=lambda x: x.get("time") or "", reverse=True)
|
||||||
|
return {"results": items[:15]}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/team")
|
@app.get("/team")
|
||||||
def my_team(authorization: str | None = Header(default=None)):
|
def my_team(authorization: str | None = Header(default=None)):
|
||||||
"""我的团队:咨询过的专家自动沉淀"""
|
"""我的团队:咨询过的专家自动沉淀"""
|
||||||
|
|||||||
@@ -2327,10 +2327,16 @@ class FactFinder:
|
|||||||
"questions": ["预期利润率大约是多少?", "投资回收期预计多长?"]},
|
"questions": ["预期利润率大约是多少?", "投资回收期预计多长?"]},
|
||||||
],
|
],
|
||||||
"life": [
|
"life": [
|
||||||
{"key": "context", "desc": "具体情境",
|
{"key": "situation", "desc": "具体情境",
|
||||||
"questions": ["能具体描述一下当前的情况吗?", "最希望解决的核心问题是什么?"]},
|
"questions": ["这件事具体是怎么发生的?能描述一下经过吗?", "最希望解决的核心问题是什么?"]},
|
||||||
{"key": "constraint", "desc": "时间和资源限制",
|
{"key": "people", "desc": "涉及的人",
|
||||||
"questions": ["目前有多少时间可以投入?", "有哪些资源可以利用?"]},
|
"questions": ["这件事涉及哪些人?他们的态度如何?", "对方目前是什么反应?"]},
|
||||||
|
{"key": "duration", "desc": "持续时间和频率",
|
||||||
|
"questions": ["这种情况持续多久了?是偶尔发生还是经常出现?", "上次类似情况是什么时候?"]},
|
||||||
|
{"key": "attempt", "desc": "已尝试的解决方式",
|
||||||
|
"questions": ["之前有没有尝试过什么方法来解决?", "效果如何?"]},
|
||||||
|
{"key": "goal", "desc": "期望的结果",
|
||||||
|
"questions": ["你期望达到什么样的结果?", "最理想的状态是什么?"]},
|
||||||
],
|
],
|
||||||
"emotion": [
|
"emotion": [
|
||||||
{"key": "feeling", "desc": "当前情绪状态",
|
{"key": "feeling", "desc": "当前情绪状态",
|
||||||
@@ -2388,6 +2394,24 @@ class FactFinder:
|
|||||||
"压力": ["feeling", "trigger"],
|
"压力": ["feeling", "trigger"],
|
||||||
"焦虑": ["feeling", "trigger"],
|
"焦虑": ["feeling", "trigger"],
|
||||||
"失眠": ["feeling", "trigger"],
|
"失眠": ["feeling", "trigger"],
|
||||||
|
# 生活场景关键词
|
||||||
|
"夫妻": ["situation", "people", "attempt"],
|
||||||
|
"吵架": ["situation", "people", "duration"],
|
||||||
|
"争吵": ["situation", "people", "duration"],
|
||||||
|
"婚姻": ["situation", "people", "attempt"],
|
||||||
|
"亲子": ["situation", "people", "duration"],
|
||||||
|
"孩子": ["situation", "people", "goal"],
|
||||||
|
"教育": ["situation", "goal", "people"],
|
||||||
|
"升学": ["situation", "goal", "attempt"],
|
||||||
|
"养老": ["situation", "people", "attempt"],
|
||||||
|
"父母": ["situation", "people", "duration"],
|
||||||
|
"健康": ["situation", "duration", "attempt"],
|
||||||
|
"体检": ["situation", "goal", "attempt"],
|
||||||
|
"睡眠": ["situation", "duration", "attempt"],
|
||||||
|
"出差": ["situation", "duration", "goal"],
|
||||||
|
"出行": ["situation", "duration", "goal"],
|
||||||
|
"饮食": ["situation", "duration", "attempt"],
|
||||||
|
"运动": ["situation", "duration", "goal"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, max_rounds: int = 2, max_questions_per_round: int = 3):
|
def __init__(self, max_rounds: int = 2, max_questions_per_round: int = 3):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Decisions } from '@/pages/Decisions'
|
|||||||
import { LifeAssistant } from '@/pages/LifeAssistant'
|
import { LifeAssistant } from '@/pages/LifeAssistant'
|
||||||
import { EmotionCenter } from '@/pages/EmotionCenter'
|
import { EmotionCenter } from '@/pages/EmotionCenter'
|
||||||
import { Team } from '@/pages/Team'
|
import { Team } from '@/pages/Team'
|
||||||
|
import { Experts } from '@/pages/Experts'
|
||||||
import { DecisionsLog } from '@/pages/DecisionsLog'
|
import { DecisionsLog } from '@/pages/DecisionsLog'
|
||||||
import { KnowledgeBase } from '@/pages/KnowledgeBase'
|
import { KnowledgeBase } from '@/pages/KnowledgeBase'
|
||||||
import { ChatHistory } from '@/pages/ChatHistory'
|
import { ChatHistory } from '@/pages/ChatHistory'
|
||||||
@@ -41,6 +42,7 @@ export default function App() {
|
|||||||
<Route path="life" element={<ProtectedRoute allowedRoles={['user']}><LifeAssistant /></ProtectedRoute>} />
|
<Route path="life" element={<ProtectedRoute allowedRoles={['user']}><LifeAssistant /></ProtectedRoute>} />
|
||||||
<Route path="emotion" element={<ProtectedRoute allowedRoles={['user']}><EmotionCenter /></ProtectedRoute>} />
|
<Route path="emotion" element={<ProtectedRoute allowedRoles={['user']}><EmotionCenter /></ProtectedRoute>} />
|
||||||
<Route path="team" element={<ProtectedRoute allowedRoles={['user']}><Team /></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="decisions-log" element={<ProtectedRoute allowedRoles={['user']}><DecisionsLog /></ProtectedRoute>} />
|
||||||
<Route path="knowledge" element={<ProtectedRoute allowedRoles={['user']}><KnowledgeBase /></ProtectedRoute>} />
|
<Route path="knowledge" element={<ProtectedRoute allowedRoles={['user']}><KnowledgeBase /></ProtectedRoute>} />
|
||||||
<Route path="history" element={<ProtectedRoute allowedRoles={['user']}><ChatHistory /></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">
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-base">对话</CardTitle>
|
<CardTitle className="text-base">对话</CardTitle>
|
||||||
{/* 模式选择:紧凑 segmented control */}
|
{/* 模式选择:紧凑 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) => (
|
{modes.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
/** 专家头像组件(带 fallback 首字母) */
|
/** 专家头像组件(带 fallback 首字母)
|
||||||
|
* 支持圆形(rect=false)和长方形(rect=true)两种形状
|
||||||
|
*/
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface ExpertAvatarProps {
|
interface ExpertAvatarProps {
|
||||||
name: string
|
name: string
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
size?: number
|
/** 像素尺寸(数字)或预设(xl=64, 2xl=96) */
|
||||||
|
size?: number | 'xl' | '2xl'
|
||||||
|
/** 是否长方形头像 */
|
||||||
|
shape?: 'circle' | 'rect'
|
||||||
className?: string
|
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 initial = name?.[0] || '?'
|
||||||
|
const shapeClass = shape === 'rect' ? 'rounded-lg' : 'rounded-full'
|
||||||
|
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: px, height: px }}
|
||||||
className={cn('rounded-full object-cover', className)}
|
className={cn('object-cover', shapeClass, className)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ width: size, height: size, fontSize: size * 0.4 }}
|
style={{ width: px, height: px, fontSize: px * 0.4 }}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/** 快捷指令面板(TASK-014 + TASK-015)
|
/** 快捷指令面板(TASK-014 + TASK-015)
|
||||||
* FAB 点击弹出底部抽屉,画像驱动的指令网格 + 语音/文字输入
|
* FAB 点击弹出底部抽屉,画像驱动的指令网格 + 语音/文字输入 + 场景快速入口
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { QuickCommands, Scene } from '@/types'
|
import type { QuickCommands, Scene } from '@/types'
|
||||||
@@ -17,14 +20,26 @@ const SCENE_ICONS: Record<string, typeof Scale> = {
|
|||||||
legal: Scale,
|
legal: Scale,
|
||||||
decision: TrendingUp,
|
decision: TrendingUp,
|
||||||
life: Heart,
|
life: Heart,
|
||||||
|
emotion: ShieldCheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCENE_ROUTES: Record<string, string> = {
|
const SCENE_ROUTES: Record<string, string> = {
|
||||||
legal: '/legal',
|
legal: '/legal',
|
||||||
decision: '/decisions',
|
decision: '/decisions',
|
||||||
life: '/life',
|
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) {
|
export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [commands, setCommands] = useState<QuickCommands | null>(null)
|
const [commands, setCommands] = useState<QuickCommands | null>(null)
|
||||||
@@ -60,10 +75,7 @@ export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
|
|||||||
/** 语音输入(Web Speech API) */
|
/** 语音输入(Web Speech API) */
|
||||||
const startListening = () => {
|
const startListening = () => {
|
||||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||||
if (!SpeechRecognition) {
|
if (!SpeechRecognition) return
|
||||||
// 不支持语音识别,静默降级
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (listening) {
|
if (listening) {
|
||||||
recognitionRef.current?.stop()
|
recognitionRef.current?.stop()
|
||||||
setListening(false)
|
setListening(false)
|
||||||
@@ -99,103 +111,138 @@ export function QuickCommandPanel({ open, onClose }: QuickCommandPanelProps) {
|
|||||||
<>
|
<>
|
||||||
{/* 遮罩 */}
|
{/* 遮罩 */}
|
||||||
<div
|
<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}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
{/* 底部抽屉 */}
|
{/* 底部抽屉 */}
|
||||||
<div className={cn(
|
<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',
|
'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="flex items-center gap-2">
|
<div className="h-1.5 w-10 rounded-full bg-muted" />
|
||||||
<Sparkles className="h-5 w-5 text-primary" />
|
|
||||||
<span className="font-medium text-foreground">你需要什么帮助?</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
|
||||||
<X className="h-5 w-5" />
|
{/* 顶部标题 + 关闭 */}
|
||||||
|
<div className="flex items-center justify-between px-4 pb-3 pt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
{/* 场景快速入口 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* 画像不完整提示 */}
|
{/* 画像不完整提示 */}
|
||||||
{commands?.profile_incomplete && commands.incomplete_hint && (
|
{commands?.profile_incomplete && commands.incomplete_hint && (
|
||||||
<div className="mb-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
|
<div className="mx-4 mb-3 rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
|
||||||
{commands.incomplete_hint}
|
{commands.incomplete_hint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">加载中...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 指令分组 */}
|
{/* 指令分组 */}
|
||||||
{sections.map(({ key, label }) => {
|
<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 }>) || []
|
const items = (commands?.[key as keyof QuickCommands] as Array<{ id: string; label: string; template: string }>) || []
|
||||||
if (items.length === 0) return null
|
if (items.length === 0) return null
|
||||||
const Icon = SCENE_ICONS[key] || Scale
|
const Icon = SCENE_ICONS[key] || Scale
|
||||||
return (
|
return (
|
||||||
<div key={key} className="mb-4">
|
<div key={key} className="mb-4">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{items.map((cmd) => (
|
{items.map((cmd) => (
|
||||||
<button
|
<button
|
||||||
key={cmd.id}
|
key={cmd.id}
|
||||||
onClick={() => handleCommandClick(key, cmd.template)}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{cmd.label}
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 文字/语音输入 */}
|
{/* 底部输入区 */}
|
||||||
<div className="mt-4 border-t border-border pt-4">
|
<div className="sticky bottom-0 border-t border-border bg-card p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={startListening}
|
onClick={startListening}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition',
|
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-all',
|
||||||
listening
|
listening
|
||||||
? 'bg-red-500 text-white animate-pulse'
|
? 'bg-red-500 text-white animate-pulse shadow-lg shadow-red-500/30'
|
||||||
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||||
)}
|
)}
|
||||||
aria-label="语音输入"
|
aria-label="语音输入"
|
||||||
>
|
>
|
||||||
<Mic className="h-5 w-5" />
|
<Mic className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={textInput}
|
value={textInput}
|
||||||
onChange={(e) => setTextInput(e.target.value)}
|
onChange={(e) => setTextInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleTextSubmit()}
|
onKeyDown={(e) => e.key === 'Enter' && handleTextSubmit()}
|
||||||
placeholder={listening ? '正在聆听...' : '或直接输入你的问题...'}
|
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"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleTextSubmit}
|
onClick={handleTextSubmit}
|
||||||
disabled={!textInput.trim()}
|
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"
|
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="发送"
|
aria-label="发送"
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{listening && (
|
{listening && (
|
||||||
<p className="mt-2 text-center text-xs text-muted-foreground">点击麦克风停止</p>
|
<p className="mt-2 text-center text-xs text-red-500">点击麦克风停止录音</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function AppLayout() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="md:pl-60">
|
<div className="md:pl-60">
|
||||||
<Header />
|
<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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function Fab() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
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="新建咨询"
|
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>
|
</button>
|
||||||
<QuickCommandPanel open={open} onClose={() => setOpen(false)} />
|
<QuickCommandPanel open={open} onClose={() => setOpen(false)} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,7 @@ import type {
|
|||||||
AskResponse, EmotionMode, Expert, ExpertService, Consultation,
|
AskResponse, EmotionMode, Expert, ExpertService, Consultation,
|
||||||
FileInspectResponse, HistoryMessage, HistorySession, KnowledgeDoc,
|
FileInspectResponse, HistoryMessage, HistorySession, KnowledgeDoc,
|
||||||
Scene, SessionMode, SessionResponse, Settings,
|
Scene, SessionMode, SessionResponse, Settings,
|
||||||
Matter, MatterBoard, DashboardToday, RiskAlert,
|
Matter, MatterBoard, DashboardToday, DashboardSummary, TimelineItem, RiskAlert,
|
||||||
ExpertRelation, QuickCommands,
|
ExpertRelation, QuickCommands,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
@@ -350,6 +350,8 @@ export const api = {
|
|||||||
|
|
||||||
// ===== 2.0 新增:老板台 =====
|
// ===== 2.0 新增:老板台 =====
|
||||||
getDashboardToday: () => request<DashboardToday>('/dashboard/today'),
|
getDashboardToday: () => request<DashboardToday>('/dashboard/today'),
|
||||||
|
getDashboardSummary: () => request<DashboardSummary>('/dashboard/summary'),
|
||||||
|
getDashboardTimeline: () => request<{ results: TimelineItem[] }>('/dashboard/timeline'),
|
||||||
|
|
||||||
// ===== 2.0 新增:我的团队 =====
|
// ===== 2.0 新增:我的团队 =====
|
||||||
getTeam: () => request<{ results: ExpertRelation[] }>('/team'),
|
getTeam: () => request<{ results: ExpertRelation[] }>('/team'),
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ export const USER_NAV: NavItem[] = [
|
|||||||
// 我的事项
|
// 我的事项
|
||||||
{ path: '/matters', label: '事项看板', icon: ClipboardList, group: '我的事项', tab: true },
|
{ 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: '/decisions', label: '经营决策', icon: TrendingUp, group: '智囊' },
|
||||||
{ path: '/life', label: '生活助理', icon: Heart, group: '智囊' },
|
{ path: '/life', label: '生活助理', icon: Heart, group: '智囊' },
|
||||||
{ path: '/emotion', label: '情绪中心', icon: ShieldCheck, group: '智囊' },
|
{ path: '/emotion', label: '情绪中心', icon: ShieldCheck, group: '智囊' },
|
||||||
// 我的资源
|
// 我的资源
|
||||||
{ path: '/team', label: '我的团队', icon: Users, group: '我的资源', tab: true },
|
{ path: '/team', label: '我的团队', icon: Users, group: '我的资源', tab: true },
|
||||||
|
{ path: '/experts', label: '专家库', icon: Users, group: '我的资源' },
|
||||||
{ path: '/decisions-log', label: '决策日志', icon: FileText, group: '我的资源' },
|
{ path: '/decisions-log', label: '决策日志', icon: FileText, group: '我的资源' },
|
||||||
{ path: '/knowledge', label: '知识库', icon: BookOpen, group: '我的资源' },
|
{ path: '/knowledge', label: '知识库', icon: BookOpen, group: '我的资源' },
|
||||||
{ path: '/history', label: '对话历史', icon: History, group: '我的资源' },
|
{ path: '/history', label: '对话历史', icon: History, group: '我的资源' },
|
||||||
@@ -96,6 +97,7 @@ export const HEADER_TITLES: Record<string, string> = {
|
|||||||
'/life': '生活助理',
|
'/life': '生活助理',
|
||||||
'/emotion': '情绪中心',
|
'/emotion': '情绪中心',
|
||||||
'/team': '我的团队',
|
'/team': '我的团队',
|
||||||
|
'/experts': '专家库',
|
||||||
'/decisions-log': '决策日志',
|
'/decisions-log': '决策日志',
|
||||||
'/knowledge': '知识库',
|
'/knowledge': '知识库',
|
||||||
'/history': '对话历史',
|
'/history': '对话历史',
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import { BrowserRouter } from 'react-router-dom'
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
|||||||
+345
-126
@@ -1,28 +1,133 @@
|
|||||||
/** 老板台(TASK-007 + TASK-024)
|
/** 老板台 2.0(TASK-007 + TASK-024)
|
||||||
* 替代 v1.0 Dashboard,展示"今日要事":
|
* 老板视角仪表盘:
|
||||||
* - 问候语
|
* - 执行摘要卡(AI 生成今日总结 + 建议行动)
|
||||||
* - 风险推送(AI 主动提醒)
|
* - 概览数字(风险/待确认/回复/健康度)
|
||||||
* - 高风险提醒
|
* - 待办优先级(按紧急程度排序,最紧急的置顶放大)
|
||||||
* - 待确认事项
|
* - 时间线(最近活动按时间倒序)
|
||||||
* - 专家回复
|
* - 快捷入口
|
||||||
* - 经营健康度
|
* - 经营健康度(环形)
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { LoadingState } from '@/components/common/LoadingState'
|
import { LoadingState } from '@/components/common/LoadingState'
|
||||||
import { ErrorState } from '@/components/common/ErrorState'
|
import { ErrorState } from '@/components/common/ErrorState'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { greeting, formatRelative, SCENE_LABELS, RISK_LABELS } from '@/lib/utils'
|
import { greeting, formatRelative, SCENE_LABELS, RISK_LABELS, MATTER_STATUS_LABELS, cn } from '@/lib/utils'
|
||||||
import type { DashboardToday, RiskAlert } from '@/types'
|
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() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [data, setData] = useState<DashboardToday | null>(null)
|
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 [alerts, setAlerts] = useState<RiskAlert[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -31,9 +136,14 @@ export function Dashboard() {
|
|||||||
setLoading(true); setError('')
|
setLoading(true); setError('')
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.getDashboardToday(),
|
api.getDashboardToday(),
|
||||||
|
api.getDashboardSummary().catch(() => null),
|
||||||
|
api.getDashboardTimeline().catch(() => ({ results: [] as TimelineItem[] })),
|
||||||
api.getRiskAlerts(true).catch(() => ({ results: [] as RiskAlert[] })),
|
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) })
|
.catch((e) => { setError(e.message); setLoading(false) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,124 +160,248 @@ export function Dashboard() {
|
|||||||
if (error) return <ErrorState message={error} onRetry={load} />
|
if (error) return <ErrorState message={error} onRetry={load} />
|
||||||
if (!data) return <EmptyState title="暂无数据" />
|
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 (
|
return (
|
||||||
<PageLayout title={`${data.greeting || greeting()},老板`} description="今天有需要你关注的事">
|
<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
|
<EmptyState
|
||||||
title="今天暂无要事"
|
title="今天暂无要事"
|
||||||
description="有新的事项或风险时会在这里提醒你"
|
description="有新的事项或风险时会在这里提醒你"
|
||||||
icon={Activity}
|
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 主动提醒) */}
|
{todos.length > 0 && (
|
||||||
{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>
|
|
||||||
</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>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<ClipboardCheck className="h-5 w-5 text-indigo-600" />
|
<CircleAlert className="h-5 w-5 text-primary" />
|
||||||
<span className="font-semibold">{data.pending_confirm.length} 个事项待你确认</span>
|
<span className="font-semibold text-foreground">待办优先级</span>
|
||||||
|
<Badge variant="secondary">{todos.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.pending_confirm.map((p) => (
|
{todos.map((todo, idx) => {
|
||||||
<div key={p.matter_id} className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
|
const config = PRIORITY_CONFIG[todo.priority]
|
||||||
<div className="min-w-0 flex-1">
|
const isTop = idx === 0
|
||||||
<div className="text-sm font-medium text-foreground">{p.title}</div>
|
return (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div
|
||||||
{SCENE_LABELS[p.scene]} · {formatRelative(p.updated_at)}
|
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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate('/matters')}>
|
<Button
|
||||||
去确认 <ArrowRight className="ml-1 h-3 w-3" />
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 专家回复 */}
|
{/* 时间线 + 健康度(两列) */}
|
||||||
{data.expert_replies.length > 0 && (
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<Card>
|
{/* 时间线(占 2 列) */}
|
||||||
|
{timeline.length > 0 && (
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<MessageSquare className="h-5 w-5 text-emerald-600" />
|
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||||
<span className="font-semibold">{data.expert_replies.length} 条专家回复</span>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -177,29 +411,14 @@ export function Dashboard() {
|
|||||||
{/* 经营健康度 */}
|
{/* 经营健康度 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<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" />
|
<Activity className="h-5 w-5 text-primary" />
|
||||||
<span className="font-semibold">你的经营健康度</span>
|
<span className="font-semibold text-foreground">经营健康度</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="flex items-center justify-around">
|
||||||
{[
|
<RingProgress value={data.health_score.legal} label="法务" color="text-blue-500" />
|
||||||
{ label: '法务', value: data.health_score.legal },
|
<RingProgress value={data.health_score.operation} label="经营" color="text-violet-500" />
|
||||||
{ label: '经营', value: data.health_score.operation },
|
<RingProgress value={data.health_score.compliance} label="合规" color="text-emerald-500" />
|
||||||
{ 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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { PageLayout } from '@/components/common/PageLayout'
|
import { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||||
|
import { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { TrendingUp, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
import { TrendingUp, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -55,6 +56,8 @@ export function Decisions() {
|
|||||||
title="经营决策助手"
|
title="经营决策助手"
|
||||||
description="项目承接、扩张收缩、招人、降价、业务线调整、融资合伙。情景推演,把利弊讲透。"
|
description="项目承接、扩张收缩、招人、降价、业务线调整、融资合伙。情景推演,把利弊讲透。"
|
||||||
>
|
>
|
||||||
|
<BrainTrustSwitcher />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ export function DecisionsLog() {
|
|||||||
<Card key={matter.id} className="transition-shadow hover:shadow-md">
|
<Card key={matter.id} className="transition-shadow hover:shadow-md">
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-medium text-foreground">{matter.title}</span>
|
<span className="truncate text-sm font-medium text-foreground">{matter.title}</span>
|
||||||
<Badge variant="outline" className="text-xs">{SCENE_LABELS[matter.scene]}</Badge>
|
<Badge variant="outline" className="text-xs">{SCENE_LABELS[matter.scene]}</Badge>
|
||||||
{matter.risk_level && (
|
{matter.risk_level && (
|
||||||
<Badge variant="outline" className={`text-xs ${EXECUTION_COLORS[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 { Brain, HeartHandshake, Zap, Phone, ChevronDown } from 'lucide-react'
|
||||||
import { PageLayout } from '@/components/common/PageLayout'
|
import { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||||
|
import { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { EmotionMode } from '@/types'
|
import type { EmotionMode } from '@/types'
|
||||||
@@ -58,6 +59,8 @@ export function EmotionCenter() {
|
|||||||
title="情绪价值引擎"
|
title="情绪价值引擎"
|
||||||
description="先处理情绪,再处理事情。选择适合你当前状态的模式。"
|
description="先处理情绪,再处理事情。选择适合你当前状态的模式。"
|
||||||
>
|
>
|
||||||
|
<BrainTrustSwitcher />
|
||||||
|
|
||||||
{/* 危机干预提示 */}
|
{/* 危机干预提示 */}
|
||||||
<Card className="border-rose-200 bg-rose-50">
|
<Card className="border-rose-200 bg-rose-50">
|
||||||
<CardContent className="flex items-center gap-3 py-4">
|
<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">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1">
|
<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 />}
|
{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 && (
|
{doc.doc_type && (
|
||||||
<Badge variant="secondary">{typeInfo?.label || doc.doc_type}</Badge>
|
<Badge variant="secondary">{typeInfo?.label || doc.doc_type}</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/** 法务参谋页:话题选择 + 换一批 + ChatPanel
|
/** 法务参谋页:话题选择 + 换一批 + ChatPanel
|
||||||
* scene='legal', 默认保密模式, 显示风险卡, 启用上传
|
* scene='legal', 默认保密模式, 显示风险卡, 启用上传
|
||||||
|
* 顶部含智囊场景切换栏(法务/决策/生活/情绪)
|
||||||
*/
|
*/
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { PageLayout } from '@/components/common/PageLayout'
|
import { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||||
|
import { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Briefcase, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
import { Briefcase, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -56,6 +58,8 @@ export function LegalAdvisor() {
|
|||||||
title="法务参谋"
|
title="法务参谋"
|
||||||
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁。AI 输出不替代执业律师正式意见。"
|
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁。AI 输出不替代执业律师正式意见。"
|
||||||
>
|
>
|
||||||
|
<BrainTrustSwitcher />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { PageLayout } from '@/components/common/PageLayout'
|
import { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||||
|
import { BrainTrustSwitcher } from '@/components/common/BrainTrustSwitcher'
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Heart, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
import { Heart, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -55,6 +56,8 @@ export function LifeAssistant() {
|
|||||||
title="生活助理"
|
title="生活助理"
|
||||||
description="家庭沟通、子女教育、父母养老、健康习惯、出行规划。老板的生活问题,也值得认真对待。"
|
description="家庭沟通、子女教育、父母养老、健康习惯、出行规划。老板的生活问题,也值得认真对待。"
|
||||||
>
|
>
|
||||||
|
<BrainTrustSwitcher />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/** 事项看板(TASK-008)
|
/** 事项看板(TASK-008)
|
||||||
* 案卡按状态分列展示,点击查看详情(弹窗),支持断点续接
|
* 案卡按状态分列展示,点击查看详情(弹窗),支持断点续接
|
||||||
* 移动端横向滚动看板列
|
* 桌面端:6 列看板;移动端:按状态折叠列表
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -44,11 +44,19 @@ export function Matters() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [selectedMatter, setSelectedMatter] = useState<Matter | null>(null)
|
const [selectedMatter, setSelectedMatter] = useState<Matter | null>(null)
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
/** 移动端折叠状态:默认展开有内容的列 */
|
||||||
|
const [expandedCols, setExpandedCols] = useState<Set<MatterStatus>>(new Set())
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true); setError('')
|
setLoading(true); setError('')
|
||||||
api.getMatters()
|
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) })
|
.catch((e) => { setError(e.message); setLoading(false) })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -96,11 +104,51 @@ export function Matters() {
|
|||||||
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
|
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">
|
<>
|
||||||
|
{/* 移动端:按状态折叠列表 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 桌面端:6 列看板 */}
|
||||||
|
<div className="hidden gap-4 md:grid md:grid-cols-3 md:overflow-visible lg:grid-cols-6">
|
||||||
{COLUMNS.map((status) => {
|
{COLUMNS.map((status) => {
|
||||||
const items = board?.[status] || []
|
const items = board?.[status] || []
|
||||||
return (
|
return (
|
||||||
<div key={status} className="w-64 shrink-0 md:w-auto">
|
<div key={status} className="md:w-auto">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-foreground">{MATTER_STATUS_LABELS[status]}</span>
|
<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>
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{items.length}</span>
|
||||||
@@ -118,6 +166,7 @@ export function Matters() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 案卡详情弹窗 */}
|
{/* 案卡详情弹窗 */}
|
||||||
|
|||||||
+33
-3
@@ -1,11 +1,23 @@
|
|||||||
/** 移动端"我"聚合页:头像占位+入口列表 */
|
/** 移动端"我"聚合页:头像占位+入口列表 */
|
||||||
import { NavLink } from 'react-router-dom'
|
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'
|
import { getName } from '@/lib/auth'
|
||||||
|
|
||||||
export function Me() {
|
export function Me() {
|
||||||
const name = getName()
|
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: '/profile', label: '我的画像', icon: User },
|
||||||
{ path: '/history', label: '对话历史', icon: History },
|
{ path: '/history', label: '对话历史', icon: History },
|
||||||
{ path: '/knowledge', label: '知识库', icon: BookOpen },
|
{ path: '/knowledge', label: '知识库', icon: BookOpen },
|
||||||
@@ -22,8 +34,25 @@ export function Me() {
|
|||||||
<div className="text-sm text-muted-foreground">中小企业老板</div>
|
<div className="text-sm text-muted-foreground">中小企业老板</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 智囊 */}
|
||||||
|
<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">
|
<div className="rounded-xl border border-border bg-card divide-y divide-border">
|
||||||
{items.map((item) => (
|
{resources.map((item) => (
|
||||||
<NavLink key={item.path} to={item.path} className="flex items-center gap-3 p-4 hover:bg-muted transition-colors">
|
<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" />
|
<item.icon className="h-5 w-5 text-muted-foreground" />
|
||||||
<span className="flex-1 text-sm text-foreground">{item.label}</span>
|
<span className="flex-1 text-sm text-foreground">{item.label}</span>
|
||||||
@@ -32,5 +61,6 @@ export function Me() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-18
@@ -1,5 +1,6 @@
|
|||||||
/** 我的团队(TASK-017)
|
/** 我的团队(TASK-017)
|
||||||
* 重构专家库为关系视图:咨询过的专家自动沉淀
|
* 重构专家库为关系视图:咨询过的专家自动沉淀
|
||||||
|
* 手机端:紧凑卡片布局,信息分层展示
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
@@ -7,6 +8,7 @@ import { Users, MessageSquare, Plus } from 'lucide-react'
|
|||||||
import { PageLayout } from '@/components/common/PageLayout'
|
import { PageLayout } from '@/components/common/PageLayout'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { LoadingState } from '@/components/common/LoadingState'
|
import { LoadingState } from '@/components/common/LoadingState'
|
||||||
import { ErrorState } from '@/components/common/ErrorState'
|
import { ErrorState } from '@/components/common/ErrorState'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
@@ -37,53 +39,57 @@ export function Team() {
|
|||||||
<PageLayout
|
<PageLayout
|
||||||
title="我的团队"
|
title="我的团队"
|
||||||
description={`共 ${team.length} 位专家`}
|
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 ? (
|
{team.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="你还没有咨询过专家"
|
title="你还没有咨询过专家"
|
||||||
description="开始一次咨询,问专家时会自动将专家加入你的团队"
|
description="开始一次咨询,问专家时会自动将专家加入你的团队"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
|
action={<Button onClick={() => navigate('/experts')}>浏览专家库</Button>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{team.map((member) => (
|
{team.map((member) => (
|
||||||
<Card key={member.expert_id} className="transition-shadow hover:shadow-md">
|
<Card key={member.expert_id} className="transition-shadow hover:shadow-md">
|
||||||
<CardContent className="flex items-center gap-4 p-4">
|
<CardContent className="p-3 sm:p-4">
|
||||||
|
{/* 上层:头像 + 名称 + 在线状态 */}
|
||||||
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<ExpertAvatar
|
<ExpertAvatar
|
||||||
name={member.name}
|
name={member.name}
|
||||||
avatarUrl={member.avatar_url}
|
avatarUrl={member.avatar_url}
|
||||||
size={48}
|
size={44}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span className="font-medium text-foreground">{member.name}</span>
|
<span className="truncate font-medium text-foreground">{member.name}</span>
|
||||||
<span className="text-sm text-muted-foreground">{member.title}</span>
|
<span className="truncate text-xs text-muted-foreground sm:text-sm">{member.title}</span>
|
||||||
{member.online && (
|
{member.online && (
|
||||||
<span className="flex items-center gap-1 text-xs text-emerald-600">
|
<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 className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> 在线
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
{/* 下层:标签信息 */}
|
||||||
<span>{SCENE_LABELS[member.category] || member.category}</span>
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span>·</span>
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{SCENE_LABELS[member.category] || member.category}
|
||||||
|
</Badge>
|
||||||
<span>咨询 {member.consultation_count} 次</span>
|
<span>咨询 {member.consultation_count} 次</span>
|
||||||
{member.last_consulted_at && (
|
{member.last_consulted_at && (
|
||||||
<>
|
<span className="hidden sm:inline">· 最近 {formatRelative(member.last_consulted_at)}</span>
|
||||||
<span>·</span>
|
|
||||||
<span>最近 {formatRelative(member.last_consulted_at)}</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上次话题 + 操作按钮 */}
|
||||||
{member.last_topic && (
|
{member.last_topic && (
|
||||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
<div className="mt-2 truncate rounded-md bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||||
上次话题: {member.last_topic}
|
上次话题: {member.last_topic}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="mt-2 flex justify-end">
|
||||||
<div className="flex shrink-0 flex-col gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(`/${member.category}`, { state: { expertId: member.expert_id, lastTopic: member.last_topic } })}
|
onClick={() => navigate(`/${member.category}`, { state: { expertId: member.expert_id, lastTopic: member.last_topic } })}
|
||||||
|
|||||||
@@ -235,6 +235,31 @@ export interface DashboardToday {
|
|||||||
unread_alerts: number
|
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 {
|
export interface RiskAlert {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user