feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 新增 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:
selfrelease
2026-08-31 08:38:33 +08:00
co-authored by Devin
parent 7a788bf8dc
commit 4de36547cd
88 changed files with 15237 additions and 62 deletions
+509
View File
@@ -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>
)
}
+209
View File
@@ -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>
)
}
+124
View File
@@ -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>
)
}
+209
View File
@@ -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>
)
}
+122
View File
@@ -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>
)
}
+404
View File
@@ -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>
)
}
+125
View File
@@ -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>
)
}
+124
View File
@@ -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>
)
}
+208
View File
@@ -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>
)
}
+228
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+535
View File
@@ -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>
)
}
+12
View File
@@ -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>
)
}
+233
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}