feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
- 新增 web2/ 前端:任务卡看板、风险雷达、决策日志、我的团队、快捷指令 - ChatPanel 增强:风险卡置顶、案卡接入、起诉状向导、语音输入 - 事项看板:点击查看详情弹窗、断点续接(恢复历史对话+matterId) - 保密模式恢复:密码验证后重建内存 session,保留多轮上下文 - 多轮对话:ask/ask-stream/ask-analyze 注入 session 历史,AI 有上下文 - 追问优化:紧扣用户问题,不再偏离主题(社保问社保,工伤问工伤) - 对话区可滚动,输入区固定可见 - 状态机放宽:允许从任意非终态转到 pending_expert - session 存活检查端点,后端重启后自动重建 session - DB migration:风险推送表、案卡表 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,509 @@
|
||||
/** 对话历史页:按场景分组列表、查看详情、保密模式密码验证、删除会话 */
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
History,
|
||||
Lock,
|
||||
Trash2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { Markdown } from '@/components/common/Markdown'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn, formatTime } from '@/lib/utils'
|
||||
import type { HistorySession, HistoryMessage, SessionMode } from '@/types'
|
||||
|
||||
/** 模式标签配置 */
|
||||
const MODE_CONFIG: Record<SessionMode, { label: string; icon: typeof History; color: string }> = {
|
||||
normal: { label: '普通模式', icon: MessageSquare, color: 'bg-blue-100 text-blue-700' },
|
||||
confidential: { label: '保密模式', icon: Lock, color: 'bg-amber-100 text-amber-700' },
|
||||
ephemeral: { label: '阅后即焚', icon: Shield, color: 'bg-rose-100 text-rose-700' },
|
||||
}
|
||||
|
||||
/** 风险等级颜色 */
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
green: 'bg-green-100 text-green-700',
|
||||
amber: 'bg-yellow-100 text-yellow-700',
|
||||
orange: 'bg-orange-100 text-orange-700',
|
||||
red: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
/** 知识库文档类型标签映射 */
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
law: '法规',
|
||||
case: '案例',
|
||||
guide: '指南',
|
||||
industry: '行业',
|
||||
template: '模板',
|
||||
}
|
||||
|
||||
/** 场景中文映射 */
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
legal: '法务参谋',
|
||||
decision: '经营决策',
|
||||
life: '生活助理',
|
||||
emotion: '情绪中心',
|
||||
}
|
||||
|
||||
export function ChatHistory() {
|
||||
const [sessions, setSessions] = useState<HistorySession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'normal' | 'confidential'>('all')
|
||||
|
||||
// 当前查看的会话
|
||||
const [currentSession, setCurrentSession] = useState<HistorySession | null>(null)
|
||||
const [messages, setMessages] = useState<HistoryMessage[]>([])
|
||||
const [msgLoading, setMsgLoading] = useState(false)
|
||||
const [msgError, setMsgError] = useState<string | null>(null)
|
||||
const [needVerify, setNeedVerify] = useState(false)
|
||||
|
||||
// 保密模式密码验证
|
||||
const [verifyPassword, setVerifyPassword] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [deleteTarget, setDeleteTarget] = useState<HistorySession | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const mode = modeFilter === 'all' ? undefined : modeFilter
|
||||
const res = await api.listHistorySessions(mode)
|
||||
setSessions(res.results)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [modeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [loadSessions])
|
||||
|
||||
/** 查看会话历史 */
|
||||
const handleView = useCallback(async (session: HistorySession) => {
|
||||
setCurrentSession(session)
|
||||
setMessages([])
|
||||
setMsgError(null)
|
||||
setNeedVerify(false)
|
||||
setVerifyPassword('')
|
||||
setMsgLoading(true)
|
||||
try {
|
||||
const res = await api.getHistoryMessages(session.session_id)
|
||||
if (res.need_verify) {
|
||||
setNeedVerify(true)
|
||||
} else {
|
||||
setMessages(res.messages || [])
|
||||
}
|
||||
} catch (err) {
|
||||
setMsgError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setMsgLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 保密模式密码验证 */
|
||||
const handleVerify = useCallback(async () => {
|
||||
if (!currentSession || !verifyPassword) return
|
||||
setVerifying(true)
|
||||
setMsgError(null)
|
||||
try {
|
||||
const res = await api.verifyHistory(currentSession.session_id, verifyPassword)
|
||||
setMessages(res.messages)
|
||||
setNeedVerify(false)
|
||||
toast.success('验证通过,已解密历史消息')
|
||||
} catch (err) {
|
||||
setMsgError(err instanceof Error ? err.message : '验证失败')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}, [currentSession, verifyPassword])
|
||||
|
||||
/** 删除会话历史 */
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.deleteHistory(deleteTarget.session_id)
|
||||
toast.success('已删除该会话历史')
|
||||
setDeleteTarget(null)
|
||||
// 如果删除的是当前查看的会话,返回列表
|
||||
if (currentSession?.session_id === deleteTarget.session_id) {
|
||||
setCurrentSession(null)
|
||||
}
|
||||
loadSessions()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除失败')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTarget, currentSession, loadSessions])
|
||||
|
||||
/** 返回列表 */
|
||||
const handleBack = useCallback(() => {
|
||||
setCurrentSession(null)
|
||||
setMessages([])
|
||||
setNeedVerify(false)
|
||||
setVerifyPassword('')
|
||||
}, [])
|
||||
|
||||
// ===== 会话详情视图 =====
|
||||
if (currentSession) {
|
||||
const modeCfg = MODE_CONFIG[currentSession.mode]
|
||||
return (
|
||||
<PageLayout
|
||||
title="对话历史"
|
||||
description="查看和管理历史对话记录"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(currentSession)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex items-center gap-3 border-b pb-3">
|
||||
<modeCfg.icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{currentSession.title || '未命名对话'}</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
|
||||
{currentSession.scene && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{SCENE_LABELS[currentSession.scene] || currentSession.scene}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
|
||||
{' · '}{currentSession.message_count} 条消息
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保密模式密码验证 */}
|
||||
{needVerify ? (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<div className="mb-4 flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
||||
<Lock className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">保密模式历史</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
该会话为加密存储,请输入登录密码验证后查看
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="verify-pwd" className="text-xs">登录密码</Label>
|
||||
<Input
|
||||
id="verify-pwd"
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleVerify()}
|
||||
placeholder="请输入登录密码"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{msgError && <p className="text-xs text-destructive">{msgError}</p>}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleVerify}
|
||||
disabled={verifying || !verifyPassword}
|
||||
>
|
||||
{verifying ? (
|
||||
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />验证中</>
|
||||
) : (
|
||||
'验证并查看'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : msgLoading ? (
|
||||
<LoadingState label="加载历史消息..." />
|
||||
) : msgError ? (
|
||||
<ErrorState message={msgError} onRetry={() => handleView(currentSession)} />
|
||||
) : messages.length === 0 ? (
|
||||
<EmptyState icon={MessageSquare} title="暂无消息" description="该会话没有历史记录" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 会话列表视图 =====
|
||||
return (
|
||||
<PageLayout
|
||||
title="对话历史"
|
||||
description="普通模式可直接查看,保密模式需密码验证,阅后即焚不落库"
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-md border bg-card p-1">
|
||||
{(['all', 'normal', 'confidential'] as const).map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={modeFilter === m ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setModeFilter(m)}
|
||||
>
|
||||
{m === 'all' ? '全部' : m === 'normal' ? '普通' : '保密'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingState label="加载历史会话..." />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadSessions} />
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="暂无历史会话"
|
||||
description="普通模式和保密模式的对话会保存在这里,阅后即焚不落库"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* 按场景分组,每组内按时间倒序 */}
|
||||
{(() => {
|
||||
// 定义场景顺序
|
||||
const SCENE_ORDER = ['legal', 'decision', 'life', 'emotion']
|
||||
// 按 scene 分组(无 scene 归为"其他")
|
||||
const groups = new Map<string, HistorySession[]>()
|
||||
for (const s of sessions) {
|
||||
const key = s.scene || 'other'
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(s)
|
||||
}
|
||||
// 每组内按 last_message_at 倒序
|
||||
for (const items of groups.values()) {
|
||||
items.sort((a, b) => {
|
||||
const ta = a.last_message_at ? new Date(a.last_message_at).getTime() : 0
|
||||
const tb = b.last_message_at ? new Date(b.last_message_at).getTime() : 0
|
||||
return tb - ta
|
||||
})
|
||||
}
|
||||
// 渲染:按 SCENE_ORDER 顺序,最后是 other
|
||||
const orderedKeys = [...SCENE_ORDER.filter(k => groups.has(k)), ...['other']].filter(k => groups.has(k))
|
||||
return orderedKeys.map((key) => (
|
||||
<div key={key}>
|
||||
<p className="mb-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{SCENE_LABELS[key] || '其他'}
|
||||
<span className="ml-1.5 text-muted-foreground/50">{groups.get(key)!.length}</span>
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{groups.get(key)!.map((session) => {
|
||||
const modeCfg = MODE_CONFIG[session.mode]
|
||||
return (
|
||||
<Card
|
||||
key={session.session_id}
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={() => handleView(session)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', modeCfg.color)}>
|
||||
<modeCfg.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{session.title || '未命名对话'}</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.message_count} 条 · {formatTime(session.last_message_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单条消息气泡 */
|
||||
function MessageBubble({ message }: { message: HistoryMessage }) {
|
||||
const isUser = message.role === 'user'
|
||||
const riskLevel = message.risk?.level
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('max-w-[80%] rounded-lg p-3', isUser ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
|
||||
{/* 消息正文:用户消息纯文本,AI 消息 Markdown 渲染 */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
) : (
|
||||
<Markdown content={message.content} className="text-sm" />
|
||||
)}
|
||||
|
||||
{/* AI 回复的风险卡信息 */}
|
||||
{!isUser && riskLevel && (
|
||||
<div className="mt-2 space-y-1 border-t border-border/30 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn('text-xs', RISK_COLORS[riskLevel])}>
|
||||
{riskLevel === 'green' ? '低风险' : riskLevel === 'amber' ? '需补证' : riskLevel === 'orange' ? '建议复核' : '高风险'}
|
||||
</Badge>
|
||||
{message.risk?.escalation && (
|
||||
<Badge variant="destructive" className="text-xs">建议升级</Badge>
|
||||
)}
|
||||
{message.meta?.inference_location && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
{message.meta.inference_location === 'cloud' ? '云端' : '本地'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{message.risk?.actions && message.risk.actions.length > 0 && (
|
||||
<ul className="text-xs opacity-80">
|
||||
{message.risk.actions.map((a, i) => (
|
||||
<li key={i}>• {a}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考来源:知识库引用 tag 或 AI 提供 tag */}
|
||||
{!isUser && (
|
||||
<div className="mt-2 border-t border-border/30 pt-2">
|
||||
<div className="mb-1 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<BookOpen className="h-3 w-3" /> 参考来源
|
||||
</div>
|
||||
{message.meta?.rag_context && message.meta.rag_context.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{message.meta.rag_context.map((doc, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center gap-1 text-xs">
|
||||
<span className="font-medium">{doc.title}</span>
|
||||
{doc.doc_type && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
|
||||
</Badge>
|
||||
)}
|
||||
{doc.region && (
|
||||
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间戳 */}
|
||||
<p className={cn('mt-1 text-[10px]', isUser ? 'text-primary-foreground/60' : 'text-muted-foreground')}>
|
||||
{formatTime(message.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 删除确认弹窗 */
|
||||
function DeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
deleting,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
deleting: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除对话历史</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除该会话的所有历史消息吗?此操作不可恢复。
|
||||
{open && ' 保密模式的加密数据将一并清除。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={deleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={deleting}>
|
||||
{deleting ? (
|
||||
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />删除中</>
|
||||
) : (
|
||||
'确认删除'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/** 老板台(TASK-007 + TASK-024)
|
||||
* 替代 v1.0 Dashboard,展示"今日要事":
|
||||
* - 问候语
|
||||
* - 风险推送(AI 主动提醒)
|
||||
* - 高风险提醒
|
||||
* - 待确认事项
|
||||
* - 专家回复
|
||||
* - 经营健康度
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, ClipboardCheck, MessageSquare, Activity, ArrowRight, Radar } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { api } from '@/lib/api'
|
||||
import { greeting, formatRelative, SCENE_LABELS, RISK_LABELS } from '@/lib/utils'
|
||||
import type { DashboardToday, RiskAlert } from '@/types'
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<DashboardToday | null>(null)
|
||||
const [alerts, setAlerts] = useState<RiskAlert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = () => {
|
||||
setLoading(true); setError('')
|
||||
Promise.all([
|
||||
api.getDashboardToday(),
|
||||
api.getRiskAlerts(true).catch(() => ({ results: [] as RiskAlert[] })),
|
||||
])
|
||||
.then(([d, a]) => { setData(d); setAlerts(a.results); setLoading(false) })
|
||||
.catch((e) => { setError(e.message); setLoading(false) })
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
const handleReadAlert = async (id: number) => {
|
||||
try {
|
||||
await api.readRiskAlert(id)
|
||||
setAlerts((prev) => prev.filter((a) => a.id !== id))
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="加载今日要事..." />
|
||||
if (error) return <ErrorState message={error} onRetry={load} />
|
||||
if (!data) return <EmptyState title="暂无数据" />
|
||||
|
||||
const hasContent = data.high_risks.length > 0 || data.pending_confirm.length > 0 || data.expert_replies.length > 0
|
||||
|
||||
return (
|
||||
<PageLayout title={`${data.greeting || greeting()},老板`} description="今天有需要你关注的事">
|
||||
{!hasContent && (
|
||||
<EmptyState
|
||||
title="今天暂无要事"
|
||||
description="有新的事项或风险时会在这里提醒你"
|
||||
icon={Activity}
|
||||
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 风险推送(AI 主动提醒) */}
|
||||
{alerts.length > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-orange-700">
|
||||
<Radar className="h-5 w-5" />
|
||||
<span className="font-semibold">{alerts.length} 条风险提醒</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{alerts.map((a) => (
|
||||
<div key={a.id} className="flex items-start justify-between rounded-lg bg-white p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${a.level === 'red' ? 'bg-red-500' : a.level === 'orange' ? 'bg-orange-500' : 'bg-blue-500'}`} />
|
||||
<span className="text-sm font-medium text-foreground">{a.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.content}</p>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleReadAlert(a.id)}>稍后</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate('/knowledge')}>查看</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 高风险提醒 */}
|
||||
{data.high_risks.length > 0 && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-red-700">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-semibold">{data.high_risks.length} 条高风险提醒</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.high_risks.map((r) => (
|
||||
<div key={r.matter_id} className="flex items-center justify-between rounded-lg bg-white p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{r.title}</span>
|
||||
<span className="text-xs text-red-600">{RISK_LABELS[r.level]}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{SCENE_LABELS[r.scene]}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={() => navigate(`/matters`)}>
|
||||
立即处理
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 待确认事项 */}
|
||||
{data.pending_confirm.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<ClipboardCheck className="h-5 w-5 text-indigo-600" />
|
||||
<span className="font-semibold">{data.pending_confirm.length} 个事项待你确认</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.pending_confirm.map((p) => (
|
||||
<div key={p.matter_id} className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{p.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{SCENE_LABELS[p.scene]} · {formatRelative(p.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate('/matters')}>
|
||||
去确认 <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 专家回复 */}
|
||||
{data.expert_replies.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<MessageSquare className="h-5 w-5 text-emerald-600" />
|
||||
<span className="font-semibold">{data.expert_replies.length} 条专家回复</span>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 经营健康度 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">你的经营健康度</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: '法务', value: data.health_score.legal },
|
||||
{ label: '经营', value: data.health_score.operation },
|
||||
{ label: '合规', value: data.health_score.compliance },
|
||||
].map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{s.label}</span>
|
||||
<span className="font-medium text-foreground">{s.value}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full ${s.value >= 70 ? 'bg-emerald-500' : s.value >= 40 ? 'bg-amber-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${s.value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/** 经营决策页:话题选择 + 换一批 + ChatPanel
|
||||
* scene='decision', 默认普通模式, 不显示风险卡
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { TrendingUp, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 话题类型 */
|
||||
interface Topic {
|
||||
title: string
|
||||
desc: string
|
||||
samples: string[]
|
||||
}
|
||||
|
||||
/** 经营决策助手:初始兜底话题,换一批时 AI 动态生成 */
|
||||
const FALLBACK_TOPICS: Topic[] = [
|
||||
{ title: '扩张收缩', desc: '开店、关店、业务线', samples: ['现在适合扩张开第二家店吗?', '要不要关闭亏损的业务线?', '新业务线该自营还是加盟?', '应该先扩张还是先巩固?'] },
|
||||
{ title: '项目承接', desc: '大单、长期合同', samples: ['是否应该接付款周期90天的大单?', '长期框架合同要不要签?', '预付款比例低的项目能接吗?', '客户要求独家合作利弊?'] },
|
||||
{ title: '定价策略', desc: '涨价、降价、促销', samples: ['该不该降价15%抢竞品客户?', '产品涨价怎么跟客户沟通?', '促销活动会不会损害品牌?', '成本上涨如何调整定价?'] },
|
||||
{ title: '招人裁员', desc: '招聘、解雇、绩效', samples: ['现在该招人还是裁人?', '裁掉绩效差的员工怎么操作?', '关键岗位招人还是培养?', '外包和正式员工怎么选?'] },
|
||||
{ title: '融资合伙', desc: '融资、合伙、股权', samples: ['现在适合融资吗?', '合伙人要退股怎么谈条件?', '出让多少股权合适?', '银行贷款还是股权融资?'] },
|
||||
{ title: '转型外包', desc: '转型、外包、调整', samples: ['业务转型方向怎么选?', '哪些业务可以外包?', '转型期间现金流怎么管理?', '外包质量怎么控?'] },
|
||||
]
|
||||
|
||||
export function Decisions() {
|
||||
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS)
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
|
||||
const [loadingTopics, setLoadingTopics] = useState(false)
|
||||
|
||||
/** 换一批:调用 AI 动态生成话题(避免重复) */
|
||||
const refreshTopics = useCallback(async () => {
|
||||
setLoadingTopics(true)
|
||||
try {
|
||||
const res = await api.getTopics('decision', topics.map((t) => t.title))
|
||||
if (res.topics?.length > 0) {
|
||||
setTopics(res.topics)
|
||||
setSelectedTopic(0)
|
||||
}
|
||||
} catch {
|
||||
// AI 不可用时保持当前话题
|
||||
} finally {
|
||||
setLoadingTopics(false)
|
||||
}
|
||||
}, [topics])
|
||||
|
||||
const currentTopic = topics[selectedTopic]
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="经营决策助手"
|
||||
description="项目承接、扩张收缩、招人、降价、业务线调整、融资合伙。情景推演,把利弊讲透。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
|
||||
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
|
||||
决策话题
|
||||
</button>
|
||||
<button
|
||||
onClick={refreshTopics}
|
||||
disabled={loadingTopics}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
|
||||
>
|
||||
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
换一批
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{!topicsCollapsed && (
|
||||
<CardContent>
|
||||
{loadingTopics && (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
AI 正在生成新话题…
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
|
||||
{topics.map((t, i) => (
|
||||
<button
|
||||
key={t.title + i}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<TrendingUp className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{currentTopic && (
|
||||
<ChatPanel
|
||||
scene="decision"
|
||||
samples={currentTopic.samples}
|
||||
topic={currentTopic.title}
|
||||
defaultMode="normal"
|
||||
placeholder={`描述你的${currentTopic.title}问题…`}
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
footer="AI 提供情景推演和利弊分析,最终决策由老板做出。"
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/** 决策日志(TASK-025)
|
||||
* 闭环案卡按月分组展示,支持标记执行结果
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { FileText, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
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 {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { formatDate, SCENE_LABELS, RISK_LABELS } from '@/lib/utils'
|
||||
import type { Matter } from '@/types'
|
||||
|
||||
interface DecisionsLogResponse {
|
||||
results: Matter[]
|
||||
by_month: Record<string, Matter[]>
|
||||
stats: { total: number; executed: number; execution_rate: number }
|
||||
}
|
||||
|
||||
const EXECUTION_ICONS: Record<string, typeof CheckCircle> = {
|
||||
'顺利': CheckCircle,
|
||||
'受阻': AlertCircle,
|
||||
'未执行': XCircle,
|
||||
}
|
||||
|
||||
const EXECUTION_COLORS: Record<string, string> = {
|
||||
'顺利': 'text-emerald-600',
|
||||
'受阻': 'text-amber-600',
|
||||
'未执行': 'text-red-600',
|
||||
}
|
||||
|
||||
export function DecisionsLog() {
|
||||
const [data, setData] = useState<DecisionsLogResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [selectedMatter, setSelectedMatter] = useState<Matter | null>(null)
|
||||
const [execResult, setExecResult] = useState<string>('')
|
||||
const [execNote, setExecNote] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setError('')
|
||||
api.getDecisionsLog()
|
||||
.then((d) => { setData(d); setLoading(false) })
|
||||
.catch((e) => { setError(e.message); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
const handleMarkResult = (matter: Matter) => {
|
||||
setSelectedMatter(matter)
|
||||
setExecResult(matter.execution_result || '')
|
||||
setExecNote(matter.execution_note || '')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveResult = async () => {
|
||||
if (!selectedMatter) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.updateExecutionResult(selectedMatter.id, execResult, execNote)
|
||||
toast.success('执行结果已更新')
|
||||
setDialogOpen(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="加载决策日志..." />
|
||||
if (error) return <ErrorState message={error} onRetry={load} />
|
||||
|
||||
const months = data ? Object.keys(data.by_month).sort().reverse() : []
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="决策日志"
|
||||
description={data ? `共 ${data.stats.total} 个决策 · 执行率 ${data.stats.execution_rate}%` : ''}
|
||||
>
|
||||
{!data || data.stats.total === 0 ? (
|
||||
<EmptyState
|
||||
title="还没有闭环的决策"
|
||||
description="完成事项后会自动归档到决策日志,支持标记执行结果进行复盘"
|
||||
icon={FileText}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* 月度统计条 */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-6 p-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">{data.stats.total}</div>
|
||||
<div className="text-xs text-muted-foreground">总决策</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-emerald-600">{data.stats.executed}</div>
|
||||
<div className="text-xs text-muted-foreground">顺利执行</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">{data.stats.execution_rate}%</div>
|
||||
<div className="text-xs text-muted-foreground">执行率</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 按月分组 */}
|
||||
{months.map((month) => (
|
||||
<div key={month}>
|
||||
<h3 className="mb-2 text-sm font-medium text-muted-foreground">{month}</h3>
|
||||
<div className="space-y-2">
|
||||
{data.by_month[month].map((matter) => {
|
||||
const ExecIcon = matter.execution_result ? EXECUTION_ICONS[matter.execution_result] : null
|
||||
return (
|
||||
<Card key={matter.id} className="transition-shadow hover:shadow-md">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{matter.title}</span>
|
||||
<Badge variant="outline" className="text-xs">{SCENE_LABELS[matter.scene]}</Badge>
|
||||
{matter.risk_level && (
|
||||
<Badge variant="outline" className={`text-xs ${EXECUTION_COLORS[matter.risk_level] || ''}`}>
|
||||
{RISK_LABELS[matter.risk_level]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{matter.summary && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{matter.summary}</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatDate(matter.closed_at)}</span>
|
||||
{ExecIcon && matter.execution_result && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className={EXECUTION_COLORS[matter.execution_result]}>
|
||||
<ExecIcon className="mr-1 inline h-3 w-3" />
|
||||
{matter.execution_result}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => handleMarkResult(matter)}>
|
||||
标记结果
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标记执行结果弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>标记决策执行结果</DialogTitle>
|
||||
<DialogDescription>{selectedMatter?.title}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['顺利', '受阻', '未执行'].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`rounded-lg border p-3 text-sm transition ${
|
||||
execResult === r
|
||||
? 'border-primary bg-primary/5 text-primary ring-1 ring-primary/20'
|
||||
: 'border-border text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => setExecResult(r)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-muted-foreground">备注(可选)</label>
|
||||
<Textarea
|
||||
value={execNote}
|
||||
onChange={(e) => setExecNote(e.target.value)}
|
||||
placeholder="记录执行情况..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSaveResult} disabled={saving || !execResult}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/** 情绪中心页:三模式选择(冷静/陪伴/推动) + ChatPanel
|
||||
* scene='emotion', 默认阅后即焚, 三模式选择
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Brain, HeartHandshake, Zap, Phone, ChevronDown } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EmotionMode } from '@/types'
|
||||
|
||||
const MODES: { value: EmotionMode; label: string; desc: string; icon: typeof Brain; samples: string[] }[] = [
|
||||
{
|
||||
value: 'calm',
|
||||
label: '冷静分析',
|
||||
desc: '事实、风险、方案优先,克制客观',
|
||||
icon: Brain,
|
||||
samples: [
|
||||
'合伙人背叛了,我很愤怒,该怎么处理?',
|
||||
'明天的谈判我拖延了一周没准备',
|
||||
'员工集体离职,我该怎么应对?',
|
||||
'竞品抄袭我们,理性分析该怎么反击',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'companion',
|
||||
label: '陪伴疏导',
|
||||
desc: '先承接情绪,再给出下一步,温和不评判',
|
||||
icon: HeartHandshake,
|
||||
samples: [
|
||||
'最近压力很大,睡不着觉',
|
||||
'感觉撑不下去了',
|
||||
'家人不理解我创业的辛苦',
|
||||
'今天被客户骂了,很难受',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'push',
|
||||
label: '强力推动',
|
||||
desc: '把拖延拆成今天可完成的小动作,直接有力',
|
||||
icon: Zap,
|
||||
samples: [
|
||||
'一直拖延不想做年底复盘',
|
||||
'知道该运动但就是不动',
|
||||
'积压了一堆邮件不想处理',
|
||||
'该打的催款电话一直拖着不打',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 情绪价值引擎:三模式切换 + 危机干预入口 */
|
||||
export function EmotionCenter() {
|
||||
const [emotionMode, setEmotionMode] = useState<EmotionMode>('calm')
|
||||
const [modesCollapsed, setModesCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="情绪价值引擎"
|
||||
description="先处理情绪,再处理事情。选择适合你当前状态的模式。"
|
||||
>
|
||||
{/* 危机干预提示 */}
|
||||
<Card className="border-rose-200 bg-rose-50">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<Phone className="h-5 w-5 shrink-0 text-rose-600" aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-rose-900">危机干预</p>
|
||||
<p className="text-xs text-rose-700">
|
||||
如果你正在经历强烈痛苦或有自伤念头,请立即拨打心理援助热线 400-161-9995 或前往最近医院急诊。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 情绪模式选择器 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<button
|
||||
onClick={() => setModesCollapsed(!modesCollapsed)}
|
||||
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', modesCollapsed && '-rotate-90')} />
|
||||
回复模式
|
||||
</button>
|
||||
</CardHeader>
|
||||
{!modesCollapsed && (
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => setEmotionMode(m.value)}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-md border p-4 text-left transition-colors',
|
||||
emotionMode === m.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<m.icon className="mb-1 h-5 w-5 text-primary" aria-hidden />
|
||||
<span className="text-sm font-medium">{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{m.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ChatPanel
|
||||
scene="emotion"
|
||||
samples={MODES.find((m) => m.value === emotionMode)?.samples || []}
|
||||
topic={MODES.find((m) => m.value === emotionMode)?.label}
|
||||
defaultMode="ephemeral"
|
||||
emotionMode={emotionMode}
|
||||
placeholder="说说你现在的感受…"
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
footer="情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。"
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
/** 知识库管理页:7 类分类,录入、检索、删除,要素表模板预览 */
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
BookOpen,
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
FileText,
|
||||
Scale,
|
||||
FileEdit,
|
||||
MapPin,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Factory,
|
||||
Heart,
|
||||
Coffee,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { KnowledgeDoc } from '@/types'
|
||||
|
||||
/** 知识库文档类型配置 */
|
||||
const DOC_TYPES = [
|
||||
{ value: 'law', label: '法规', icon: Scale },
|
||||
{ value: 'case', label: '案例', icon: FileText },
|
||||
{ value: 'template', label: '模板', icon: FileEdit },
|
||||
{ value: 'guide', label: '经营指南', icon: TrendingUp },
|
||||
{ value: 'industry', label: '行业知识', icon: Factory },
|
||||
{ value: 'emotion', label: '情绪疏导', icon: Heart },
|
||||
{ value: 'life', label: '生活常识', icon: Coffee },
|
||||
{ value: 'element_template', label: '要素表模板', icon: FileText },
|
||||
]
|
||||
|
||||
/** 来源英文 → 中文映射 */
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
seed: '系统内置',
|
||||
manual: '手动录入',
|
||||
import: '导入',
|
||||
api: 'API 录入',
|
||||
}
|
||||
|
||||
/** 法规知识库管理:7 类分类,录入、检索、删除 */
|
||||
export function KnowledgeBase() {
|
||||
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
|
||||
const [counts, setCounts] = useState<Record<string, number>>({})
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<KnowledgeDoc[] | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgeDoc | null>(null)
|
||||
|
||||
// 新增表单
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
doc_type: 'law',
|
||||
region: '',
|
||||
effective_date: '',
|
||||
keywords: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadDocs = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.listKnowledge(filterType !== 'all' ? filterType : undefined)
|
||||
setDocs(res.results)
|
||||
setCounts(res.counts || {})
|
||||
setTotalCount(res.total || 0)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDocs()
|
||||
}, [filterType])
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await api.searchKnowledge(searchQuery.trim())
|
||||
setSearchResults(res.results)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '检索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.title.trim() || !form.content.trim()) {
|
||||
toast.error('标题和内容不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.addKnowledge({
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
doc_type: form.doc_type,
|
||||
region: form.region.trim() || null,
|
||||
effective_date: form.effective_date.trim() || null,
|
||||
keywords: form.keywords.trim() || null,
|
||||
})
|
||||
toast.success('文档已添加')
|
||||
setAddOpen(false)
|
||||
setForm({ title: '', content: '', doc_type: 'law', region: '', effective_date: '', keywords: '' })
|
||||
loadDocs()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '添加失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return
|
||||
try {
|
||||
await api.deleteKnowledge(deleteTarget.id)
|
||||
toast.success('文档已删除')
|
||||
setDeleteTarget(null)
|
||||
loadDocs()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const displayDocs = searchResults ?? docs
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="知识库"
|
||||
description="7 类知识库:法规、案例、模板、经营指南、行业知识、情绪疏导、生活常识。提问时按场景自动注入相关知识。"
|
||||
actions={
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-3.5 w-3.5" /> 新增文档
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-w-[95vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增知识文档</DialogTitle>
|
||||
<DialogDescription>录入后将进入 RAG 检索库,提问时按场景自动引用相关知识。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="kb-title">标题</Label>
|
||||
<Input id="kb-title" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:劳动合同法-试用期解除" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-content">内容</Label>
|
||||
<Textarea id="kb-content" value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} placeholder="法规条文、案例摘要或模板正文" className="min-h-[120px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="kb-type">类型</Label>
|
||||
<Select value={form.doc_type} onValueChange={(v) => setForm({ ...form, doc_type: v })}>
|
||||
<SelectTrigger id="kb-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOC_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-region">地区</Label>
|
||||
<Input id="kb-region" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="如:全国 / 上海" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="kb-date">生效日期</Label>
|
||||
<Input id="kb-date" type="date" value={form.effective_date} onChange={(e) => setForm({ ...form, effective_date: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-keywords">关键词</Label>
|
||||
<Input id="kb-keywords" value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="逗号分隔,如:试用期,解除" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAdd} disabled={saving}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />} 保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
>
|
||||
{/* 检索栏 */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="检索法规、案例、经营指南、行业知识…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={searching}>
|
||||
{searching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
|
||||
检索
|
||||
</Button>
|
||||
{searchResults && (
|
||||
<Button variant="outline" onClick={() => { setSearchResults(null); setSearchQuery('') }}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 类型筛选 */}
|
||||
{!searchResults && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filterType === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType('all')}
|
||||
>
|
||||
全部 <span className="ml-1 text-xs opacity-70">{totalCount}</span>
|
||||
</Button>
|
||||
{DOC_TYPES.map((t) => (
|
||||
<Button
|
||||
key={t.value}
|
||||
variant={filterType === t.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType(t.value)}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" /> {t.label}
|
||||
<span className="ml-1 text-xs opacity-70">{counts[t.value] || 0}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 文档列表 */}
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadDocs} />
|
||||
) : displayDocs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={searchResults ? '未找到匹配文档' : '知识库为空'}
|
||||
description={searchResults ? '尝试更换关键词' : '点击右上角新增文档,录入法规、案例、经营指南等知识'}
|
||||
icon={BookOpen}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{searchResults && (
|
||||
<p className="text-sm text-muted-foreground">找到 {searchResults.length} 条匹配结果</p>
|
||||
)}
|
||||
{displayDocs.map((doc) => {
|
||||
const typeInfo = DOC_TYPES.find((t) => t.value === doc.doc_type)
|
||||
return (
|
||||
<Card key={doc.id} className={cn(searchResults && doc.score && 'border-l-4 border-l-primary')}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{typeInfo && <typeInfo.icon className="h-4 w-4 text-muted-foreground" aria-hidden />}
|
||||
<h3 className="font-medium">{doc.title}</h3>
|
||||
{doc.doc_type && (
|
||||
<Badge variant="secondary">{typeInfo?.label || doc.doc_type}</Badge>
|
||||
)}
|
||||
{searchResults && doc.score && (
|
||||
<Badge variant="outline">相关度 {doc.score}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{doc.doc_type !== 'element_template' && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
|
||||
)}
|
||||
{doc.doc_type === 'element_template' && (() => {
|
||||
try {
|
||||
const tmpl = JSON.parse(doc.content)
|
||||
return (
|
||||
<div className="mt-2 rounded border bg-muted/30 p-3 text-xs">
|
||||
<p className="font-medium">案由:{tmpl.cause_of_action}</p>
|
||||
<p className="mt-0.5 text-muted-foreground">适用:{tmpl.applicable}</p>
|
||||
<p className="mt-2 font-medium">要素清单({tmpl.elements?.length || 0}项)</p>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
|
||||
{tmpl.elements?.map((e: Record<string, string>, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{e.label}</span>
|
||||
<span className="font-medium">{e.type}{e.required === '是' ? ' ·必填' : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tmpl.claims?.length > 0 && (
|
||||
<>
|
||||
<p className="mt-2 font-medium">诉讼请求</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tmpl.claims.map((c: string, i: number) => (
|
||||
<Badge key={i} variant="outline" className="text-[10px]">{c}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} catch { return null }
|
||||
})()}
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" /> 来源:{SOURCE_LABELS[doc.source] || doc.source}
|
||||
</span>
|
||||
{doc.region && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" /> {doc.region}
|
||||
</span>
|
||||
)}
|
||||
{doc.effective_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" /> {doc.effective_date}
|
||||
</span>
|
||||
)}
|
||||
{doc.keywords && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" /> {doc.keywords}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!searchResults && doc.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-rose-600"
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除?</DialogTitle>
|
||||
<DialogDescription>将永久删除「{deleteTarget?.title}」,此操作不可恢复。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** 法务参谋页:话题选择 + 换一批 + ChatPanel
|
||||
* scene='legal', 默认保密模式, 显示风险卡, 启用上传
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Briefcase, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 话题类型 */
|
||||
interface Topic {
|
||||
title: string
|
||||
desc: string
|
||||
samples: string[]
|
||||
}
|
||||
|
||||
/** 法务参谋:初始兜底话题,换一批时 AI 动态生成 */
|
||||
// 初始兜底话题(同步显示,无 loading)
|
||||
const FALLBACK_TOPICS: Topic[] = [
|
||||
{ title: '劳动用工', desc: '工伤、辞退、欠薪、社保', samples: ['员工工伤后能开除吗?', '员工被辞退要赔多少?', '员工不签合同有何风险?', '社保未缴全被查怎么办?'] },
|
||||
{ title: '合同审查', desc: '违约、条款、效力', samples: ['合同违约金怎么约定?', '对方不履行合同怎么办?', '口头协议有法律效力吗?', '合同条款不公平能改吗?'] },
|
||||
{ title: '债权债务', desc: '欠款、催收、诉讼', samples: ['客户拖欠货款3个月如何催收?', '借条怎么写才有效?', '债务人不还钱能报警吗?', '超过诉讼时效怎么办?'] },
|
||||
{ title: '股东纠纷', desc: '股权、分红、退股', samples: ['合伙人要退股怎么谈?', '股东不出资怎么办?', '分红比例怎么定?', '小股东权益被侵害怎么办?'] },
|
||||
{ title: '税务风险', desc: '稽查、发票、合规', samples: ['收到税务稽查通知怎么办?', '发票开错了怎么处理?', '个体户怎么交税最合理?', '税务罚款能减免吗?'] },
|
||||
{ title: '诉讼仲裁', desc: '起诉、举证、执行', samples: ['打官司要准备哪些证据?', '仲裁和诉讼哪个更快?', '赢了官司对方不执行怎么办?', '诉讼费用怎么算?'] },
|
||||
]
|
||||
|
||||
export function LegalAdvisor() {
|
||||
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS)
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
|
||||
const [loadingTopics, setLoadingTopics] = useState(false)
|
||||
|
||||
/** 换一批:调用 AI 动态生成话题(避免重复) */
|
||||
const refreshTopics = useCallback(async () => {
|
||||
setLoadingTopics(true)
|
||||
try {
|
||||
const res = await api.getTopics('legal', topics.map((t) => t.title))
|
||||
if (res.topics?.length > 0) {
|
||||
setTopics(res.topics)
|
||||
setSelectedTopic(0)
|
||||
}
|
||||
} catch {
|
||||
// AI 不可用时保持当前话题
|
||||
} finally {
|
||||
setLoadingTopics(false)
|
||||
}
|
||||
}, [topics])
|
||||
|
||||
const currentTopic = topics[selectedTopic]
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="法务参谋"
|
||||
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁。AI 输出不替代执业律师正式意见。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
|
||||
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
|
||||
法务话题
|
||||
</button>
|
||||
<button
|
||||
onClick={refreshTopics}
|
||||
disabled={loadingTopics}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
|
||||
>
|
||||
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
换一批
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{!topicsCollapsed && (
|
||||
<CardContent>
|
||||
{loadingTopics && (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
AI 正在生成新话题…
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
|
||||
{topics.map((t, i) => (
|
||||
<button
|
||||
key={t.title + i}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Briefcase className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{currentTopic && (
|
||||
<ChatPanel
|
||||
scene="legal"
|
||||
samples={currentTopic.samples}
|
||||
topic={currentTopic.title}
|
||||
defaultMode="confidential"
|
||||
placeholder={`描述你的${currentTopic.title}问题…`}
|
||||
showRiskCard
|
||||
enableUpload
|
||||
footer="AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。重大风险事项请寻求执业专业人士复核。"
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/** 生活助理页:话题选择 + 换一批 + ChatPanel
|
||||
* scene='life', 默认保密模式
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { ChatPanel } from '@/components/common/ChatPanel'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Heart, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 话题类型 */
|
||||
interface Topic {
|
||||
title: string
|
||||
desc: string
|
||||
samples: string[]
|
||||
}
|
||||
|
||||
/** 生活助理:初始兜底话题,换一批时 AI 动态生成 */
|
||||
const FALLBACK_TOPICS: Topic[] = [
|
||||
{ title: '家庭沟通', desc: '亲子、夫妻、关系', samples: ['怎么和青春期的孩子沟通?', '夫妻因工作吵架怎么办?', '家人不支持创业怎么说服?', '工作太忙忽略家人怎么办?'] },
|
||||
{ title: '子女教育', desc: '升学、兴趣、规划', samples: ['孩子不想上学怎么办?', '兴趣班该报几个?', '要不要送孩子出国?', '孩子成绩下滑怎么沟通?'] },
|
||||
{ title: '父母养老', desc: '养老、医疗、陪伴', samples: ['父母年纪大了养老方案怎么选?', '异地父母怎么照顾?', '养老院还是居家养老?', '父母医疗费用怎么分担?'] },
|
||||
{ title: '睡眠与压力', desc: '失眠、焦虑、调节', samples: ['最近睡眠很差怎么改善?', '工作压力大怎么缓解?', '焦虑到影响工作怎么办?', '怎么判断需要看心理医生?'] },
|
||||
{ title: '出差日程', desc: '出行、时间管理', samples: ['出差频繁怎么兼顾家庭?', '行程太满怎么优化?', '出差期间公司怎么管?', '如何减少无效出差?'] },
|
||||
{ title: '健康习惯', desc: '体检、饮食、运动', samples: ['体检报告几项异常怎么办?', '久坐怎么改善健康?', '应酬多怎么控制饮食?', '没时间运动怎么办?'] },
|
||||
]
|
||||
|
||||
export function LifeAssistant() {
|
||||
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS)
|
||||
const [selectedTopic, setSelectedTopic] = useState(0)
|
||||
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
|
||||
const [loadingTopics, setLoadingTopics] = useState(false)
|
||||
|
||||
/** 换一批:调用 AI 动态生成话题(避免重复) */
|
||||
const refreshTopics = useCallback(async () => {
|
||||
setLoadingTopics(true)
|
||||
try {
|
||||
const res = await api.getTopics('life', topics.map((t) => t.title))
|
||||
if (res.topics?.length > 0) {
|
||||
setTopics(res.topics)
|
||||
setSelectedTopic(0)
|
||||
}
|
||||
} catch {
|
||||
// AI 不可用时保持当前话题
|
||||
} finally {
|
||||
setLoadingTopics(false)
|
||||
}
|
||||
}, [topics])
|
||||
|
||||
const currentTopic = topics[selectedTopic]
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="生活助理"
|
||||
description="家庭沟通、子女教育、父母养老、健康习惯、出行规划。老板的生活问题,也值得认真对待。"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
|
||||
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
|
||||
生活话题
|
||||
</button>
|
||||
<button
|
||||
onClick={refreshTopics}
|
||||
disabled={loadingTopics}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
|
||||
>
|
||||
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
换一批
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{!topicsCollapsed && (
|
||||
<CardContent>
|
||||
{loadingTopics && (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
AI 正在生成新话题…
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
|
||||
{topics.map((t, i) => (
|
||||
<button
|
||||
key={t.title + i}
|
||||
onClick={() => setSelectedTopic(i)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
|
||||
selectedTopic === i
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Heart className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{currentTopic && (
|
||||
<ChatPanel
|
||||
scene="life"
|
||||
samples={currentTopic.samples}
|
||||
topic={currentTopic.title}
|
||||
defaultMode="confidential"
|
||||
placeholder={`描述你的${currentTopic.title}问题…`}
|
||||
showRiskCard={false}
|
||||
enableUpload={false}
|
||||
footer="AI 输出仅供参考,重大事项请咨询相关领域专业人士。"
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/** 登录/注册页:手机号+密码,支持用户/专家/管理员三种角色(独立全屏,不用 PageLayout) */
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { setAuth, type Role } from '@/lib/auth'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, ShieldCheck, Briefcase, Shield, User, UserCog } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 角色配置(含示例账号,方便演示和测试) */
|
||||
const ROLES: Array<{
|
||||
value: Role
|
||||
label: string
|
||||
icon: typeof User
|
||||
desc: string
|
||||
home: string
|
||||
testAccount: { phone: string; password: string; name: string }
|
||||
}> = [
|
||||
{
|
||||
value: 'user', label: '用户', icon: User, desc: '老板专属经营助手', home: '/',
|
||||
testAccount: { phone: '13800138000', password: '123456', name: '测试老板' },
|
||||
},
|
||||
{
|
||||
value: 'expert', label: '专家', icon: Briefcase, desc: '专家工作台,接单回复', home: '/workbench',
|
||||
testAccount: { phone: '13910000001', password: 'expert123', name: '李华律师' },
|
||||
},
|
||||
{
|
||||
value: 'admin', label: '管理员', icon: Shield, desc: '平台管理后台', home: '/admin',
|
||||
testAccount: { phone: '13800000000', password: 'admin123', name: '平台管理员' },
|
||||
},
|
||||
]
|
||||
|
||||
/** 登录/注册页:手机号+密码,角色Tab(user/expert/admin) */
|
||||
export function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [role, setRole] = useState<Role>('user')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const currentRole = ROLES.find((r) => r.value === role)!
|
||||
|
||||
/** 一键填充示例账号 */
|
||||
const fillTestAccount = () => {
|
||||
if (currentRole.testAccount) {
|
||||
setPhone(currentRole.testAccount.phone)
|
||||
setPassword(currentRole.testAccount.password)
|
||||
setName(currentRole.testAccount.name)
|
||||
setMode('login')
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换角色时清空表单 */
|
||||
const switchRole = (r: Role) => {
|
||||
setRole(r)
|
||||
setPhone('')
|
||||
setPassword('')
|
||||
setName('')
|
||||
setMode('login')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!phone.trim()) { toast.error('请输入手机号'); return }
|
||||
if (!password.trim()) { toast.error('请输入密码'); return }
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) { toast.error('手机号格式不正确,应为 11 位'); return }
|
||||
if (password.length < 6) { toast.error('密码至少 6 位'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
if (role !== 'user' && mode === 'register') {
|
||||
toast.error('专家和管理员账号由平台统一创建,无需注册')
|
||||
setMode('login')
|
||||
return
|
||||
}
|
||||
const result = mode === 'login'
|
||||
? await api.login(phone, password)
|
||||
: await api.register(phone, password, name)
|
||||
const resultRole: Role = mode === 'login'
|
||||
? ((result as { role?: string }).role as Role) || 'user'
|
||||
: 'user'
|
||||
setAuth(result.token, result.phone, result.name, resultRole)
|
||||
toast.success(mode === 'login' ? '登录成功' : '注册成功')
|
||||
const targetRole = ROLES.find((r) => r.value === resultRole) || currentRole
|
||||
navigate(targetRole.home)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '操作失败'
|
||||
toast.error(msg, { duration: 4000 })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{mode === 'login' ? '登录 左右(BossAi)' : '注册 左右(BossAi)'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{currentRole.desc}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 角色 Tab */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg border p-2.5 text-sm transition',
|
||||
role === r.value
|
||||
? 'border-primary bg-primary/5 text-primary ring-1 ring-primary/20'
|
||||
: 'border-border text-muted-foreground hover:bg-muted/40',
|
||||
)}
|
||||
onClick={() => switchRole(r.value)}
|
||||
>
|
||||
<r.icon className="h-4 w-4" />
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
{mode === 'register' && role === 'user' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">昵称(可选)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="怎么称呼你?"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === 'login' ? '登录' : '注册'}
|
||||
</Button>
|
||||
|
||||
{/* 注册入口仅用户显示 */}
|
||||
{role === 'user' && (
|
||||
<div className="text-center text-sm">
|
||||
{mode === 'login' ? (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
没有账号?去注册
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
已有账号?去登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 示例账号快捷填充 */}
|
||||
{currentRole.testAccount && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3 text-center">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{currentRole.label}示例账号 {currentRole.testAccount.phone}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fillTestAccount} disabled={loading}>
|
||||
<UserCog className="mr-2 h-3.5 w-3.5" />
|
||||
一键填充
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Login 页在 AppLayout 之外,需自带 Toaster 才能显示 toast */}
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/** 事项看板(TASK-008)
|
||||
* 案卡按状态分列展示,点击查看详情(弹窗),支持断点续接
|
||||
* 移动端横向滚动看板列
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ClipboardList, Plus, Scale, TrendingUp, Heart, ShieldCheck, ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { MatterCard } from '@/components/common/MatterCard'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn, MATTER_STATUS_LABELS, MATTER_STATUS_COLORS, SCENE_LABELS, RISK_LABELS, formatRelative, formatDate } from '@/lib/utils'
|
||||
import type { Matter, MatterBoard, MatterStatus, Scene } from '@/types'
|
||||
|
||||
/** 看板列定义(顺序即展示顺序) */
|
||||
const COLUMNS: MatterStatus[] = ['new', 'asking_facts', 'pending_confirm', 'analyzing', 'pending_expert', 'closed']
|
||||
|
||||
const SCENE_ICONS: Record<Scene, typeof Scale> = {
|
||||
legal: Scale,
|
||||
decision: TrendingUp,
|
||||
life: Heart,
|
||||
emotion: ShieldCheck,
|
||||
}
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
red: 'text-red-600 border-red-200 bg-red-50',
|
||||
orange: 'text-orange-600 border-orange-200 bg-orange-50',
|
||||
amber: 'text-amber-600 border-amber-200 bg-amber-50',
|
||||
green: 'text-green-600 border-green-200 bg-green-50',
|
||||
}
|
||||
|
||||
export function Matters() {
|
||||
const navigate = useNavigate()
|
||||
const [board, setBoard] = useState<MatterBoard | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedMatter, setSelectedMatter] = useState<Matter | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setError('')
|
||||
api.getMatters()
|
||||
.then((r) => { setBoard(r.board); setTotal(r.total); setLoading(false) })
|
||||
.catch((e) => { setError(e.message); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
/** 点击案卡:加载详情并弹出 */
|
||||
const handleCardClick = (matter: Matter) => {
|
||||
setSelectedMatter(matter)
|
||||
setDetailLoading(true)
|
||||
api.getMatter(matter.id)
|
||||
.then((detail) => { setSelectedMatter(detail); setDetailLoading(false) })
|
||||
.catch(() => { setDetailLoading(false) })
|
||||
}
|
||||
|
||||
/** 继续处理:跳转对应场景(带 session_id + matter_id 断点续接) */
|
||||
const handleContinue = () => {
|
||||
if (!selectedMatter) return
|
||||
const scene = selectedMatter.scene
|
||||
if (selectedMatter.session_id) {
|
||||
navigate(`/${scene}?sid=${selectedMatter.session_id}&mid=${selectedMatter.id}`)
|
||||
} else {
|
||||
navigate(`/${scene}`)
|
||||
}
|
||||
setSelectedMatter(null)
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="加载事项看板..." />
|
||||
if (error) return <ErrorState message={error} onRetry={load} />
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="事项看板"
|
||||
description={`共 ${total} 个事项`}
|
||||
actions={
|
||||
<Button size="sm" onClick={() => navigate('/legal')}>
|
||||
<Plus className="mr-1 h-4 w-4" /> 新建咨询
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{total === 0 ? (
|
||||
<EmptyState
|
||||
title="还没有事项"
|
||||
description="开始一次咨询,系统会自动为你创建案卡,追踪进度"
|
||||
icon={ClipboardList}
|
||||
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 md:grid md:grid-cols-3 md:gap-4 md:overflow-visible lg:grid-cols-6">
|
||||
{COLUMNS.map((status) => {
|
||||
const items = board?.[status] || []
|
||||
return (
|
||||
<div key={status} className="w-64 shrink-0 md:w-auto">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">{MATTER_STATUS_LABELS[status]}</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{items.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2 min-h-[100px] rounded-xl bg-muted/30 p-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">暂无</div>
|
||||
) : (
|
||||
items.map((m) => (
|
||||
<MatterCard key={m.id} matter={m} onClick={() => handleCardClick(m)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 案卡详情弹窗 */}
|
||||
<Dialog open={!!selectedMatter} onOpenChange={(open) => !open && setSelectedMatter(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{/* 加载中时提供隐藏的 Title/Description 满足 Radix a11y 要求 */}
|
||||
{detailLoading && (
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>加载中</DialogTitle>
|
||||
<DialogDescription>正在加载案卡详情</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedMatter ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = SCENE_ICONS[selectedMatter.scene] || Scale
|
||||
return <Icon className="h-5 w-5 text-primary" />
|
||||
})()}
|
||||
{selectedMatter.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedMatter.code} · {SCENE_LABELS[selectedMatter.scene]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 状态 + 风险等级 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn('inline-block rounded-full border px-3 py-1 text-xs font-medium', MATTER_STATUS_COLORS[selectedMatter.status])}>
|
||||
{MATTER_STATUS_LABELS[selectedMatter.status]}
|
||||
</span>
|
||||
{selectedMatter.risk_level && (
|
||||
<span className={cn('inline-block rounded-full border px-3 py-1 text-xs font-medium', RISK_COLORS[selectedMatter.risk_level] || '')}>
|
||||
{RISK_LABELS[selectedMatter.risk_level]}风险
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 摘要 */}
|
||||
{selectedMatter.summary && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">事项摘要</p>
|
||||
<p className="rounded-lg bg-muted/50 p-3 text-sm text-foreground">{selectedMatter.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 执行结果(已闭环) */}
|
||||
{selectedMatter.status === 'closed' && selectedMatter.execution_result && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">执行结果</p>
|
||||
<div className="rounded-lg border p-3">
|
||||
<Badge variant="outline" className={cn(
|
||||
'mb-1',
|
||||
selectedMatter.execution_result === '顺利' ? 'text-emerald-600' :
|
||||
selectedMatter.execution_result === '受阻' ? 'text-amber-600' : 'text-red-600'
|
||||
)}>
|
||||
{selectedMatter.execution_result}
|
||||
</Badge>
|
||||
{selectedMatter.execution_note && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{selectedMatter.execution_note}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">创建时间</span>
|
||||
<p className="mt-0.5 font-medium">{formatDate(selectedMatter.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">更新时间</span>
|
||||
<p className="mt-0.5 font-medium">{formatRelative(selectedMatter.updated_at)}</p>
|
||||
</div>
|
||||
{selectedMatter.closed_at && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">闭环时间</span>
|
||||
<p className="mt-0.5 font-medium">{formatDate(selectedMatter.closed_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{selectedMatter.status !== 'closed' ? (
|
||||
<Button onClick={handleContinue} size="sm">
|
||||
继续处理 <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => navigate('/decisions-log')} size="sm">
|
||||
查看决策日志 <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** 移动端"我"聚合页:头像占位+入口列表 */
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { User, History, BookOpen, Settings as SettingsIcon, ChevronRight } from 'lucide-react'
|
||||
import { getName } from '@/lib/auth'
|
||||
|
||||
export function Me() {
|
||||
const name = getName()
|
||||
const items = [
|
||||
{ path: '/profile', label: '我的画像', icon: User },
|
||||
{ path: '/history', label: '对话历史', icon: History },
|
||||
{ path: '/knowledge', label: '知识库', icon: BookOpen },
|
||||
{ path: '/settings', label: '设置', icon: SettingsIcon },
|
||||
]
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
|
||||
{name?.[0] || '老'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">{name || '老板'}</div>
|
||||
<div className="text-sm text-muted-foreground">中小企业老板</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card divide-y divide-border">
|
||||
{items.map((item) => (
|
||||
<NavLink key={item.path} to={item.path} className="flex items-center gap-3 p-4 hover:bg-muted transition-colors">
|
||||
<item.icon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm text-foreground">{item.label}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { UserCircle, History, ChevronRight, ArrowLeft } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn, formatTime } from '@/lib/utils'
|
||||
|
||||
/** 用户画像数据 */
|
||||
interface ProfileData {
|
||||
phone: string
|
||||
industry: string
|
||||
company_size: string
|
||||
role: string
|
||||
common_scenes: string[]
|
||||
common_topics: string[]
|
||||
risk_tolerance: string
|
||||
communication_style: string
|
||||
decision_style: string
|
||||
key_facts: Record<string, string>
|
||||
interaction_count: number
|
||||
last_active: string
|
||||
}
|
||||
|
||||
/** 画像历史版本 */
|
||||
interface ProfileHistory {
|
||||
id: number
|
||||
version: number
|
||||
profile: Record<string, unknown>
|
||||
source: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 字段中文标签 */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
industry: '行业',
|
||||
company_size: '企业规模',
|
||||
role: '角色',
|
||||
risk_tolerance: '风险偏好',
|
||||
communication_style: '沟通偏好',
|
||||
decision_style: '决策风格',
|
||||
}
|
||||
|
||||
/** 企业规模映射 */
|
||||
const SIZE_LABELS: Record<string, string> = {
|
||||
micro: '微型(<10人)',
|
||||
small: '小型(10-50人)',
|
||||
medium: '中型(50-200人)',
|
||||
large: '大型(200+人)',
|
||||
}
|
||||
|
||||
/** 风险偏好映射 */
|
||||
const RISK_LABELS: Record<string, string> = {
|
||||
conservative: '保守型',
|
||||
moderate: '稳健型',
|
||||
aggressive: '进取型',
|
||||
}
|
||||
|
||||
/** 角色映射 */
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
owner: '老板/创始人',
|
||||
manager: '管理者',
|
||||
ceo: 'CEO',
|
||||
director: '总监',
|
||||
}
|
||||
|
||||
/** 决策风格映射 */
|
||||
const DECISION_STYLE_LABELS: Record<string, string> = {
|
||||
'data-driven': '数据驱动型',
|
||||
intuitive: '直觉型',
|
||||
collaborative: '协作型',
|
||||
}
|
||||
|
||||
/** 沟通偏好映射 */
|
||||
const COMMUNICATION_STYLE_LABELS: Record<string, string> = {
|
||||
concise: '简洁',
|
||||
detailed: '详细',
|
||||
structured: '结构化',
|
||||
}
|
||||
|
||||
/** 行业映射 */
|
||||
const INDUSTRY_LABELS: Record<string, string> = {
|
||||
retail: '零售',
|
||||
manufacturing: '制造业',
|
||||
tech: '科技',
|
||||
service: '服务业',
|
||||
finance: '金融',
|
||||
realestate: '房地产',
|
||||
education: '教育',
|
||||
healthcare: '医疗健康',
|
||||
construction: '建筑',
|
||||
logistics: '物流',
|
||||
agriculture: '农业',
|
||||
hospitality: '餐饮酒店',
|
||||
}
|
||||
|
||||
/** key_facts 的 key 中文映射 */
|
||||
const KEY_FACT_LABELS: Record<string, string> = {
|
||||
region: '地区',
|
||||
company_type: '公司类型',
|
||||
team_ability: '团队能力',
|
||||
communication: '沟通情况',
|
||||
medical_proof: '医疗证明',
|
||||
contract_terms: '合同条款',
|
||||
medical_period: '医疗期',
|
||||
financial_status: '财务状况',
|
||||
injury_situation: '工伤情况',
|
||||
legal_procedures: '法律程序',
|
||||
market_potential: '市场潜力',
|
||||
alternative_option: '备选方案',
|
||||
work_injury_details: '工伤详情',
|
||||
alternative_solution: '替代方案',
|
||||
work_injury_insurance: '工伤保险',
|
||||
performance_assessment: '绩效评估',
|
||||
relevant_legal_provisions: '相关法律条款',
|
||||
employee_count: '员工人数',
|
||||
revenue: '营收',
|
||||
profit_status: '盈利状况',
|
||||
business_model: '商业模式',
|
||||
funding_stage: '融资阶段',
|
||||
expansion_plan: '扩张计划',
|
||||
competitor: '竞争对手',
|
||||
customer_concentration: '客户集中度',
|
||||
supply_chain: '供应链',
|
||||
cash_flow: '现金流',
|
||||
asset_status: '资产状况',
|
||||
liability: '负债',
|
||||
contract_dispute: '合同纠纷',
|
||||
debt_amount: '债务金额',
|
||||
tax_risk: '税务风险',
|
||||
litigation_status: '诉讼状态',
|
||||
shareholder_structure: '股权结构',
|
||||
partnership: '合伙关系',
|
||||
salary_status: '薪资状况',
|
||||
social_security: '社保',
|
||||
severance: '经济补偿',
|
||||
working_years: '工作年限',
|
||||
probation_period: '试用期',
|
||||
termination_reason: '解雇原因',
|
||||
overtime_status: '加班情况',
|
||||
export_capability: '出口能力',
|
||||
family_situation: '家庭情况',
|
||||
health_status: '健康状况',
|
||||
travel_plan: '出行计划',
|
||||
important_event: '重要事项',
|
||||
education_background: '教育背景',
|
||||
stress_source: '压力来源',
|
||||
personality: '性格特征',
|
||||
work_time: '工作时间',
|
||||
contract_status: '合同状态',
|
||||
salary_calculation_basis: '薪资计算依据',
|
||||
injury_detail: '受伤详情',
|
||||
treatment_status: '治疗状况',
|
||||
recovery_status: '康复状况',
|
||||
compensation_claim: '赔偿诉求',
|
||||
arbitration_status: '仲裁状态',
|
||||
employment_relationship: '劳动关系',
|
||||
wage_standard: '工资标准',
|
||||
working_hours: '工时',
|
||||
leave_record: '请假记录',
|
||||
discipline_record: '违纪记录',
|
||||
training_agreement: '培训协议',
|
||||
confidentiality_agreement: '保密协议',
|
||||
non_compete: '竞业限制',
|
||||
ip_ownership: '知识产权归属',
|
||||
data_protection: '数据保护',
|
||||
compliance_status: '合规状况',
|
||||
regulatory_requirement: '监管要求',
|
||||
license_status: '许可状态',
|
||||
audit_finding: '审计发现',
|
||||
internal_control: '内部控制',
|
||||
risk_management: '风险管理',
|
||||
insurance_coverage: '保险覆盖',
|
||||
claim_status: '理赔状态',
|
||||
policy_detail: '保单详情',
|
||||
premium_status: '保费状态',
|
||||
beneficiary: '受益人',
|
||||
coverage_amount: '保额',
|
||||
deductible: '免赔额',
|
||||
policy_period: '保险期间',
|
||||
renewal_status: '续保状态',
|
||||
cancellation_reason: '退保原因',
|
||||
loan_purpose: '借款用途',
|
||||
repayment_plan: '还款计划',
|
||||
collateral: '抵押物',
|
||||
guarantor_info: '担保人',
|
||||
interest_rate: '利率',
|
||||
penalty_rate: '罚息率',
|
||||
overdue_days: '逾期天数',
|
||||
collection_status: '催收状态',
|
||||
settlement_status: '结算状态',
|
||||
bankruptcy_status: '破产状态',
|
||||
restructure_plan: '重组方案',
|
||||
liquidation_status: '清算状态',
|
||||
debt_restructuring: '债务重组',
|
||||
asset_disposal: '资产处置',
|
||||
business_transfer: '业务转让',
|
||||
merger_status: '并购状态',
|
||||
acquisition_status: '收购状态',
|
||||
divestiture: '剥离',
|
||||
joint_venture: '合资',
|
||||
strategic_alliance: '战略联盟',
|
||||
ip_valuation: '知识产权估值',
|
||||
technology_transfer: '技术转让',
|
||||
royalty_rate: '许可费率',
|
||||
infringement_scope: '侵权范围',
|
||||
damages_claim: '损害赔偿诉求',
|
||||
injunction_status: '禁令状态',
|
||||
invalidation_request: '无效宣告请求',
|
||||
opposition_status: '异议状态',
|
||||
licensing_term: '许可期限',
|
||||
territory: '地域范围',
|
||||
exclusivity: '排他性',
|
||||
sublicensing: '再许可',
|
||||
warranty_term: '保证条款',
|
||||
indemnity: '补偿条款',
|
||||
limitation_liability: '责任限制',
|
||||
force_majeure: '不可抗力',
|
||||
dispute_resolution: '争议解决',
|
||||
governing_law: '适用法律',
|
||||
jurisdiction: '管辖',
|
||||
arbitration_clause: '仲裁条款',
|
||||
mediation_status: '调解状态',
|
||||
enforcement_status: '执行状态',
|
||||
execution_status: '执行状况',
|
||||
preservation_measure: '保全措施',
|
||||
objection_status: '异议状态',
|
||||
retrial_request: '再审请求',
|
||||
appeal_status: '上诉状态',
|
||||
court_level: '法院层级',
|
||||
case_procedure: '案件程序',
|
||||
hearing_date: '开庭日期',
|
||||
evidence_status: '举证状况',
|
||||
witness_status: '证人状况',
|
||||
expert_evaluation: '专家鉴定',
|
||||
judicial_interpretation: '司法解释',
|
||||
precedent_case: '先例案例',
|
||||
legislation_reference: '法律依据',
|
||||
}
|
||||
|
||||
/** key_facts 的值中文映射 */
|
||||
const KEY_FACT_VALUE_LABELS: Record<string, string> = {
|
||||
yes: '是',
|
||||
no: '否',
|
||||
true: '是',
|
||||
false: '否',
|
||||
none: '无',
|
||||
unknown: '未知',
|
||||
'n/a': '不适用',
|
||||
pending: '待定',
|
||||
active: '活跃',
|
||||
inactive: '停用',
|
||||
profit: '盈利',
|
||||
loss: '亏损',
|
||||
expansion: '扩张',
|
||||
contraction: '收缩',
|
||||
stable: '稳定',
|
||||
growth: '增长中',
|
||||
declining: '下滑中',
|
||||
...SIZE_LABELS,
|
||||
...RISK_LABELS,
|
||||
...ROLE_LABELS,
|
||||
...INDUSTRY_LABELS,
|
||||
}
|
||||
|
||||
/** 场景中文映射 */
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
legal: '法务参谋',
|
||||
decision: '经营决策',
|
||||
life: '生活助理',
|
||||
emotion: '情绪中心',
|
||||
}
|
||||
|
||||
/** 来源映射 */
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
auto: 'AI 自动提取',
|
||||
manual: '用户确认',
|
||||
}
|
||||
|
||||
/** 格式化字段值 */
|
||||
function formatValue(key: string, value: string): string {
|
||||
if (key === 'company_size') return SIZE_LABELS[value] || value
|
||||
if (key === 'risk_tolerance') return RISK_LABELS[value] || value
|
||||
if (key === 'role') return ROLE_LABELS[value] || value
|
||||
if (key === 'decision_style') return DECISION_STYLE_LABELS[value] || value
|
||||
if (key === 'communication_style') return COMMUNICATION_STYLE_LABELS[value] || value
|
||||
if (key === 'industry') return INDUSTRY_LABELS[value] || value
|
||||
return value
|
||||
}
|
||||
|
||||
/** 翻译 key_facts 的 key */
|
||||
function translateFactKey(key: string): string {
|
||||
return KEY_FACT_LABELS[key.toLowerCase()] || key
|
||||
}
|
||||
|
||||
/** 翻译 key_facts 的值 */
|
||||
function translateFactValue(value: string): string {
|
||||
const lower = value.toLowerCase().trim()
|
||||
return KEY_FACT_VALUE_LABELS[lower] || value
|
||||
}
|
||||
|
||||
export function MyProfile() {
|
||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||
const [history, setHistory] = useState<ProfileHistory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentHistory, setCurrentHistory] = useState<ProfileHistory | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [profileRes, historyRes] = await Promise.all([
|
||||
api.getProfile(),
|
||||
api.getProfileHistory(),
|
||||
])
|
||||
setProfile(profileRes as unknown as ProfileData)
|
||||
setHistory(historyRes.results)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <PageLayout title="我的画像"><LoadingState /></PageLayout>
|
||||
if (error) return <PageLayout title="我的画像"><ErrorState message={error} onRetry={load} /></PageLayout>
|
||||
if (!profile) return null
|
||||
|
||||
// ===== 历史详情视图 =====
|
||||
if (currentHistory) {
|
||||
const hp = currentHistory.profile as Record<string, unknown>
|
||||
return (
|
||||
<PageLayout
|
||||
title={`画像版本 v${currentHistory.version}`}
|
||||
description={`${SOURCE_LABELS[currentHistory.source] || currentHistory.source} · ${formatTime(currentHistory.created_at)}`}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentHistory(null)}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="space-y-3">
|
||||
{/* 标准字段:多列网格 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
|
||||
{Object.entries(FIELD_LABELS).map(([key, label]) => {
|
||||
const val = hp[key] as string
|
||||
if (!val) return null
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between border-b pb-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium">{formatValue(key, val)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{Array.isArray(hp.common_topics) && (hp.common_topics as string[]).length > 0 && (
|
||||
<div className="border-b pb-2">
|
||||
<p className="mb-1 text-xs text-muted-foreground">常问话题</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(hp.common_topics as string[]).map((t, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-[10px] px-1 py-0">{String(t)}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(hp.key_facts && typeof hp.key_facts === 'object' && !Array.isArray(hp.key_facts) && Object.keys(hp.key_facts as Record<string, unknown>).length > 0) && (
|
||||
<div className="border-b pb-2">
|
||||
<p className="mb-1 text-xs text-muted-foreground">关键事实</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
|
||||
{Object.entries(hp.key_facts as Record<string, string>).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{translateFactKey(k)}</span>
|
||||
<span className="font-medium">{translateFactValue(String(v))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(hp.common_scenes) && (hp.common_scenes as string[]).length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">常用场景</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(hp.common_scenes as string[]).map((s, i) => (
|
||||
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
|
||||
{SCENE_LABELS[String(s)] || String(s)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 画像主视图 =====
|
||||
return (
|
||||
<PageLayout
|
||||
title="我的画像"
|
||||
description="系统根据你的对话累积的抽象特征,用于个性化回复。不存储对话原文。"
|
||||
>
|
||||
{/* 当前画像 */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<UserCircle className="h-5 w-5 text-primary" aria-hidden />
|
||||
<h3 className="text-sm font-semibold">当前画像</h3>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
交互 {profile.interaction_count} 次
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 标准字段:多列网格 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
|
||||
{Object.entries(FIELD_LABELS).map(([key, label]) => {
|
||||
const val = profile[key as keyof ProfileData] as string
|
||||
if (!val) return null
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between border-b pb-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium">{formatValue(key, val)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{profile.common_topics && profile.common_topics.length > 0 && (
|
||||
<div className="border-b pb-2">
|
||||
<p className="mb-1 text-xs text-muted-foreground">常问话题</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{profile.common_topics.map((t, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-[10px] px-1 py-0">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.key_facts && Object.keys(profile.key_facts).length > 0 && (
|
||||
<div className="border-b pb-2">
|
||||
<p className="mb-1 text-xs text-muted-foreground">关键事实</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
|
||||
{Object.entries(profile.key_facts).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{translateFactKey(k)}</span>
|
||||
<span className="font-medium">{translateFactValue(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.common_scenes && profile.common_scenes.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">常用场景</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{profile.common_scenes.map((s, i) => (
|
||||
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
|
||||
{SCENE_LABELS[s] || s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.last_active && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
最后活跃:{formatTime(profile.last_active)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 版本历史 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<h3 className="text-sm font-semibold">版本历史</h3>
|
||||
<span className="text-xs text-muted-foreground">{history.length} 个版本</span>
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无历史版本"
|
||||
description="每次画像更新都会记录版本快照"
|
||||
icon={History}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{history.map((h) => (
|
||||
<Card
|
||||
key={h.id}
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={() => setCurrentHistory(h)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className={cn(
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
|
||||
h.source === 'manual' ? 'bg-blue-100 text-blue-700' : 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
<History className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">版本 v{h.version}</p>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<Badge
|
||||
variant={h.source === 'manual' ? 'default' : 'secondary'}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{SOURCE_LABELS[h.source] || h.source}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatTime(h.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 通用占位页:未实现路由的兜底 */
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { FileQuestion } from 'lucide-react'
|
||||
|
||||
export function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<PageLayout title={title}>
|
||||
<EmptyState title="页面未找到" description={`"${title}" 暂未实现`} icon={FileQuestion} />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Shield,
|
||||
Cloud,
|
||||
Database,
|
||||
Trash2,
|
||||
Bell,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
Loader2,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { ErrorState } from '@/components/common/ErrorState'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Settings } from '@/types'
|
||||
|
||||
/** 设置项配置 */
|
||||
const SETTING_GROUPS = [
|
||||
{
|
||||
title: '隐私与会话',
|
||||
icon: Shield,
|
||||
desc: '控制数据留存和销毁策略',
|
||||
items: [
|
||||
{ key: 'show_inference_location', label: '显示推理位置', desc: '在对话中标注当前处理位置(本地/边缘/云端),保持透明', icon: Eye, danger: false },
|
||||
{ key: 'ephemeral_disable_copy', label: '阅后即焚默认禁用复制', desc: '阅后即焚模式下禁止复制、导出、转发和长期记忆', icon: EyeOff, danger: false },
|
||||
{ key: 'auto_destroy_on_exit', label: '退出会话自动销毁', desc: '离开页面或关闭应用时自动销毁阅后即焚会话', icon: Trash2, danger: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据与记忆',
|
||||
icon: Database,
|
||||
desc: '控制 AI 记忆范围和数据用途',
|
||||
items: [
|
||||
{ key: 'long_term_memory', label: '长期记忆', desc: '记住你的压力来源、性格和决策风格,提供个性化建议。可随时关闭', icon: Database, danger: false },
|
||||
{ key: 'knowledge_base_write', label: '企业知识库写入', desc: '允许将脱敏内容写入企业知识库,供团队复用', icon: Database, danger: false },
|
||||
{ key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, 左右(BossAi) 不采集训练数据', icon: Info, danger: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '云端授权',
|
||||
icon: Cloud,
|
||||
desc: '本地能力不足时,控制是否调用云端模型',
|
||||
items: [
|
||||
{ key: 'cloud_call_allowed', label: '允许云端调用', desc: '本地模型能力不足时,经授权后调用云端模型。关闭后仅使用本地/边缘推理', icon: Cloud, danger: false },
|
||||
{ key: 'pii_redact_on_transfer', label: 'PII 脱敏后传输', desc: '手机号、身份证、银行卡等敏感信息在传输前自动脱敏', icon: Shield, danger: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '通知与自动升级',
|
||||
icon: Bell,
|
||||
desc: '每日提醒、风险预警和自动升级规则',
|
||||
items: [
|
||||
{ key: 'daily_reminder', label: '每日经营提醒', desc: '每天早上推送今天最该做的一件事', icon: Bell, danger: false },
|
||||
{ key: 'risk_alert', label: '风险预警', desc: '检测到合同到期、应收逾期、员工试用期届满等风险时主动提醒', icon: Shield, danger: false },
|
||||
{ key: 'auto_escalate_red', label: '红色风险自动提醒', desc: '检测到红色风险时自动提醒用户咨询专家,无需手动操作', icon: Shield, danger: false },
|
||||
{ key: 'auto_escalate_orange', label: '橙色风险自动提醒', desc: '检测到橙色风险时自动提醒用户咨询专家(默认手动)', icon: Shield, danger: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 设置:从后端加载,修改后持久化到 PostgreSQL */
|
||||
export function Settings() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [pendingChanges, setPendingChanges] = useState<Settings>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.getSettings()
|
||||
setSettings(res.settings)
|
||||
setPendingChanges({})
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载设置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const toggle = (key: string) => {
|
||||
const current = settings?.[key] ?? 'false'
|
||||
const newValue = current === 'true' ? 'false' : 'true'
|
||||
setPendingChanges((prev) => ({ ...prev, [key]: newValue }))
|
||||
setSettings((prev) => prev ? { ...prev, [key]: newValue } : prev)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await api.updateSettings(pendingChanges)
|
||||
setSettings(res.settings)
|
||||
setPendingChanges({})
|
||||
setDirty(false)
|
||||
toast.success('设置已保存')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <PageLayout title="设置"><LoadingState /></PageLayout>
|
||||
if (error) return <PageLayout title="设置"><ErrorState message={error} onRetry={load} /></PageLayout>
|
||||
if (!settings) return null
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="设置"
|
||||
description="隐私偏好、数据留存、云端授权和通知设置。你对自己的数据拥有完全控制权。"
|
||||
actions={
|
||||
dirty ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load}>放弃</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{SETTING_GROUPS.map((group) => (
|
||||
<Card key={group.title}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<group.icon className="h-4 w-4" aria-hidden /> {group.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{group.desc}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{group.items.map((item) => {
|
||||
const on = settings[item.key] === 'true'
|
||||
const pending = pendingChanges[item.key]
|
||||
const isPending = pending !== undefined
|
||||
return (
|
||||
<div key={item.key} className="flex items-start gap-3 py-3">
|
||||
<item.icon className={cn('mt-0.5 h-5 w-5 shrink-0', item.danger ? 'text-rose-600' : 'text-primary')} aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{item.label}
|
||||
{isPending && <span className="ml-2 text-xs text-amber-600">未保存</span>}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(item.key)}
|
||||
className={cn(
|
||||
'relative h-6 w-11 shrink-0 rounded-full transition-colors',
|
||||
on ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
on ? 'translate-x-5' : 'translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 危险操作 */}
|
||||
<Card className="border-rose-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base text-rose-700">
|
||||
<Trash2 className="h-4 w-4" aria-hidden /> 危险操作
|
||||
</CardTitle>
|
||||
<CardDescription>以下操作不可恢复,请谨慎执行</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">清除全部会话历史</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">销毁所有普通和保密模式的历史会话</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-rose-300 text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">导出并删除全部数据</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">导出你的全部数据后永久删除</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-rose-300 text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
|
||||
>
|
||||
导出并删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
设置已持久化到 PostgreSQL 数据库,跨会话保留。
|
||||
</p>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/** 我的团队(TASK-017)
|
||||
* 重构专家库为关系视图:咨询过的专家自动沉淀
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Users, MessageSquare, Plus } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { 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 { formatRelative, SCENE_LABELS } from '@/lib/utils'
|
||||
import type { ExpertRelation } from '@/types'
|
||||
|
||||
export function Team() {
|
||||
const navigate = useNavigate()
|
||||
const [team, setTeam] = useState<ExpertRelation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = () => {
|
||||
setLoading(true); setError('')
|
||||
api.getTeam()
|
||||
.then((r) => { setTeam(r.results); setLoading(false) })
|
||||
.catch((e) => { setError(e.message); setLoading(false) })
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
if (loading) return <LoadingState label="加载我的团队..." />
|
||||
if (error) return <ErrorState message={error} onRetry={load} />
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="我的团队"
|
||||
description={`共 ${team.length} 位专家`}
|
||||
actions={<Button variant="outline" size="sm" onClick={() => navigate('/legal')}><Plus className="mr-1 h-4 w-4" /> 找新专家</Button>}
|
||||
>
|
||||
{team.length === 0 ? (
|
||||
<EmptyState
|
||||
title="你还没有咨询过专家"
|
||||
description="开始一次咨询,问专家时会自动将专家加入你的团队"
|
||||
icon={Users}
|
||||
action={<Button onClick={() => navigate('/legal')}>开始咨询</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{team.map((member) => (
|
||||
<Card key={member.expert_id} className="transition-shadow hover:shadow-md">
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<ExpertAvatar
|
||||
name={member.name}
|
||||
avatarUrl={member.avatar_url}
|
||||
size={48}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{member.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{member.title}</span>
|
||||
{member.online && (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> 在线
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{SCENE_LABELS[member.category] || member.category}</span>
|
||||
<span>·</span>
|
||||
<span>咨询 {member.consultation_count} 次</span>
|
||||
{member.last_consulted_at && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>最近 {formatRelative(member.last_consulted_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{member.last_topic && (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
上次话题: {member.last_topic}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/${member.category}`, { state: { expertId: member.expert_id, lastTopic: member.last_topic } })}
|
||||
>
|
||||
<MessageSquare className="mr-1 h-3.5 w-3.5" /> 新咨询
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user