Files
BossAi/web2/src/components/common/ChatPanel.tsx
T
selfrelease 2e51ee3a42
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled
fix: 换一批第3次卡住问题,修正池索引计算
2026-08-31 17:23:05 +08:00

1734 lines
66 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { useSearchParams, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import {
Send,
Upload,
Loader2,
MessageSquare,
Lock,
Flame,
Users,
HelpCircle,
RefreshCw,
Check,
CheckCircle,
FileText,
BookOpen,
Sparkles,
Download,
ChevronRight,
ChevronLeft,
} from 'lucide-react'
import { RiskCardView } from './RiskCardView'
import { Markdown } from './Markdown'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { api } from '@/lib/api'
import { getPhone } from '@/lib/auth'
import { cn } from '@/lib/utils'
import type { AskResponse, EmotionMode, ExpertService, HistoryMessage, KnowledgeDoc, RiskCard, Scene, SessionMode } from '@/types'
/** 画像字段英文值 → 中文显示映射 */
const PROFILE_VALUE_LABELS: Record<string, string> = {
// 企业规模
micro: '微型(<10人)',
small: '小型(<50人)',
medium: '中型(<200人)',
large: '大型(>200人)',
// 角色
owner: '老板/创始人',
manager: '管理者',
ceo: 'CEO',
director: '总监',
// 风险偏好
conservative: '保守型',
moderate: '稳健型',
aggressive: '进取型',
// 决策风格
'data-driven': '数据驱动型',
intuitive: '直觉型',
collaborative: '协作型',
// 沟通偏好
concise: '简洁',
detailed: '详细',
structured: '结构化',
// 通用 yes/no
yes: '是',
no: '否',
// 行业
retail: '零售',
manufacturing: '制造业',
tech: '科技',
service: '服务业',
finance: '金融',
realestate: '房地产',
education: '教育',
healthcare: '医疗健康',
construction: '建筑',
logistics: '物流',
agriculture: '农业',
hospitality: '餐饮酒店',
// 常见 key_facts 英文值
true: '是',
false: '否',
none: '无',
unknown: '未知',
'n/a': '不适用',
pending: '待定',
active: '活跃',
inactive: '停用',
profit: '盈利',
loss: '亏损',
expansion: '扩张',
contraction: '收缩',
stable: '稳定',
growth: '增长中',
declining: '下滑中',
}
/** 画像字段标签(dim_desc)英文 → 中文映射 */
const PROFILE_DIM_LABELS: Record<string, string> = {
// 标准画像字段
industry: '行业',
company_size: '企业规模',
role: '角色',
risk_tolerance: '风险偏好',
decision_style: '决策风格',
communication_style: '沟通偏好',
// key_facts 常见 key
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: '融资阶段',
partnership: '合伙关系',
shareholder_structure: '股权结构',
contract_dispute: '合同纠纷',
debt_amount: '债务金额',
tax_risk: '税务风险',
litigation_status: '诉讼状态',
employee_role: '员工角色',
salary_status: '薪资状况',
social_security: '社保',
severance: '经济补偿',
working_years: '工作年限',
probation_period: '试用期',
termination_reason: '解雇原因',
overtime_status: '加班情况',
export_capability: '出口能力',
expansion_plan: '扩张计划',
competitor: '竞争对手',
customer_concentration: '客户集中度',
supply_chain: '供应链',
cash_flow: '现金流',
asset_status: '资产状况',
liability: '负债',
}
/** 将画像字段的英文标签翻译为中文 */
function translateDimLabel(label: string): string {
const lower = label.toLowerCase().trim()
return PROFILE_DIM_LABELS[lower] ?? label
}
/** 将画像字段的英文值翻译为中文显示 */
function translateProfileValue(value: string): string {
// key_facts 类的值可能是中文也可能是英文,统一查映射表
const lower = value.toLowerCase().trim()
return PROFILE_VALUE_LABELS[lower] ?? value
}
export interface ChatPanelMode {
value: SessionMode
label: string
desc: string
Icon: typeof MessageSquare
}
export interface ChatPanelProps {
/** 业务场景,路由后端 system prompt */
scene: Scene
/** 示例问题 */
samples: string[]
/** 当前话题(用于 AI 换一批时定向生成) */
topic?: string
/** 允许的会话模式(默认全部) */
modes?: ChatPanelMode[]
/** 默认会话模式 */
defaultMode?: SessionMode
/** 情绪模式(仅 emotion 场景),由页面控制并传入 */
emotionMode?: EmotionMode
/** 情绪模式选择器(由页面渲染,通过 children 注入) */
modeSelector?: React.ReactNode
/** 输入框占位符 */
placeholder?: string
/** 是否显示风险卡片(法务场景显示,其他场景可选) */
showRiskCard?: boolean
/** 文件上传支持 */
enableUpload?: boolean
/** 卡片底部脚注(如免责声明) */
footer?: React.ReactNode
}
interface Message {
role: 'user' | 'assistant'
content: string
risk?: AskResponse['risk']
answer?: string | null
model?: string
inferenceLocation?: string
fallbackUsed?: boolean
/** 知识库引用来源 */
ragContext?: KnowledgeDoc[]
/** 流式输出中 */
streaming?: boolean
/** 事实追问回答(样式区分) */
isFactAnswer?: boolean
}
/** 知识库文档类型标签映射 */
const DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
const DEFAULT_MODES: ChatPanelMode[] = [
{ value: 'normal', label: '普通模式', desc: '日常经营,可保存历史', Icon: MessageSquare },
{ value: 'confidential', label: '保密模式', desc: '加密保存,文件自动过期', Icon: Lock },
{ value: 'ephemeral', label: '阅后即焚', desc: '退出即销毁,不留痕迹', Icon: Flame },
]
/** 生成风险卡 Markdown 文本(用于分享/导出) */
function riskCardToMarkdown(card: RiskCard, answer?: string | null): string {
const lines: string[] = []
lines.push(`# 风险判断`)
lines.push('')
lines.push(`**风险等级:** ${card.level}`)
lines.push('')
lines.push(`**结论:** ${card.conclusion}`)
lines.push('')
if (card.actions.length > 0) {
lines.push('## 立即行动')
card.actions.forEach((a) => lines.push(`- ${a}`))
lines.push('')
}
if (card.evidence.length > 0) {
lines.push('## 需保存证据')
lines.push(card.evidence.join('、'))
lines.push('')
}
if (answer) {
lines.push('## 模型分析')
lines.push(answer)
lines.push('')
}
if (card.escalation) {
lines.push('> 建议人工专业复核:本事项可能造成明显损失或涉及重大风险。')
}
return lines.join('\n')
}
/** 通用对话面板:会话创建 → 提问 → 风险卡/答复 → 文件上传 → 隐私急停
* 2.0 新增:案卡接入、风险卡置顶、起诉状三步向导、URL 参数预填、location state 传入 expertId
*/
export function ChatPanel({
scene,
samples,
topic,
modes = DEFAULT_MODES,
defaultMode = 'confidential',
emotionMode,
modeSelector,
placeholder = '描述你的问题…',
showRiskCard = true,
enableUpload = true,
footer,
}: ChatPanelProps) {
const [mode, setMode] = useState<SessionMode>(defaultMode)
const [sid, setSid] = useState<string | null>(null)
const [question, setQuestion] = useState('')
const [messages, setMessages] = useState<Message[]>([])
const [loading, setLoading] = useState(false)
const [dynamicSamples, setDynamicSamples] = useState<string[]>(samples)
const [refreshing, setRefreshing] = useState(false)
/** 当天热点问题池(从后端加载的全部问题)和当前批次起始索引 */
const [dailyPool, setDailyPool] = useState<string[]>([])
const [poolIndex, setPoolIndex] = useState(0)
const BATCH_SIZE = 12
// ===== 2.0 新增:案卡状态 =====
const [matterId, setMatterId] = useState<number | null>(null)
// ===== 2.0 新增:风险卡置顶(仅显示结论摘要) =====
const [showCloseDialog, setShowCloseDialog] = useState(false)
const [closeResult, setCloseResult] = useState<'顺利' | '受阻' | '未执行'>('顺利')
const [closeNote, setCloseNote] = useState('')
const [closing, setClosing] = useState(false)
// ===== 2.0 新增:起诉状三步向导 =====
const [filingWizardOpen, setFilingWizardOpen] = useState(false)
const [filingStep, setFilingStep] = useState(1)
const [filingElements, setFilingElements] = useState<Array<{ key: string; label: string; value: string }>>([])
const [filingClaims, setFilingClaims] = useState<string[]>([])
const [filingText, setFilingText] = useState('')
const [filingCause, setFilingCause] = useState('')
// ===== 2.0 新增:URL 参数预填 + session 恢复 =====
const [searchParams] = useSearchParams()
useEffect(() => {
const q = searchParams.get('q')
if (q) { setQuestion(q) }
// 从 URL ?mid= 恢复 matterId(断点续接时用于闭环操作)
const mid = searchParams.get('mid')
if (mid) { setMatterId(Number(mid)) }
}, [searchParams])
/** 从 URL ?sid= 恢复 session 和历史消息(断点续接) */
useEffect(() => {
const restoreSid = searchParams.get('sid')
if (!restoreSid || sid) return // 已有 session 不重复恢复
setSid(restoreSid)
setRestoringHistory(true)
// 如果 URL 没有 mid,通过 session_id 查询关联案卡恢复 matterId
const mid = searchParams.get('mid')
if (!mid) {
api.getMatterBySession(restoreSid)
.then((m) => setMatterId(m.id))
.catch(() => {/* 没有关联案卡,不影响 */})
}
api.getHistoryMessages(restoreSid)
.then((res) => {
if (res.need_verify) {
// 保密模式:需要密码验证,弹出验证框
setRestoringHistory(false)
setRestoreVerifyOpen(true)
return
}
if (res.messages && res.messages.length > 0) {
restoreMessages(res.messages)
}
})
.catch(() => {
// 历史加载失败(可能 session 已过期),静默处理
})
.finally(() => setRestoringHistory(false))
}, [searchParams, sid])
/** 恢复消息到对话区 */
const restoreMessages = (messages: HistoryMessage[]) => {
const restored = messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
answer: m.role === 'assistant' ? m.content : undefined,
risk: m.risk?.level ? {
level: m.risk.level,
conclusion: m.risk.conclusion || '',
actions: m.risk.actions || [],
evidence: m.risk.evidence || [],
escalation: m.risk.escalation || false,
} : undefined,
model: m.meta?.model,
inferenceLocation: m.meta?.inference_location,
}))
setMessages(restored)
toast.success('已恢复之前的对话,可继续提问')
}
/** 保密模式密码验证后恢复 */
const handleRestoreVerify = async () => {
if (!restorePassword.trim()) {
toast.error('请输入密码')
return
}
const restoreSid = searchParams.get('sid')
if (!restoreSid) return
setVerifyLoading(true)
try {
const res = await api.verifyHistory(restoreSid, restorePassword)
if (res.messages && res.messages.length > 0) {
restoreMessages(res.messages)
setRestoreVerifyOpen(false)
setRestorePassword('')
} else {
toast.error('没有历史消息')
setRestoreVerifyOpen(false)
}
} catch {
toast.error('密码验证失败')
} finally {
setVerifyLoading(false)
}
}
// ===== 2.0 新增:location state 传入 expertId =====
const location = useLocation()
const expertId = (location.state as any)?.expertId
const lastTopic = (location.state as any)?.lastTopic
/** 切换话题时重置为内置示例 */
useEffect(() => {
setDynamicSamples(samples)
}, [samples])
/** 挂载时取当天热点问题,有则存入池并显示首批 */
useEffect(() => {
api.getDailyTopics(scene)
.then((res) => {
if (res.questions?.length > 0) {
setDailyPool(res.questions)
setPoolIndex(0)
setDynamicSamples(res.questions.slice(0, BATCH_SIZE))
}
})
.catch(() => {/* 用兜底示例 */})
}, [scene])
const [destroying, setDestroying] = useState(false)
/** 恢复历史中(从 ?sid= 断点续接) */
const [restoringHistory, setRestoringHistory] = useState(false)
/** 保密模式恢复时密码验证弹窗 */
const [restoreVerifyOpen, setRestoreVerifyOpen] = useState(false)
const [restorePassword, setRestorePassword] = useState('')
const [verifyLoading, setVerifyLoading] = useState(false)
const [confirmDestroy, setConfirmDestroy] = useState(false)
/** 专家咨询弹窗 */
const [expertConsultOpen, setExpertConsultOpen] = useState(false)
const [expertList, setExpertList] = useState<ExpertService[]>([])
const [selectedExpert, setSelectedExpert] = useState<ExpertService | null>(null)
const [consultQuestion, setConsultQuestion] = useState('')
const [consultSupplement, setConsultSupplement] = useState('')
const [consulting, setConsulting] = useState(false)
/** 追问状态 */
const [factQuestions, setFactQuestions] = useState<Array<{ dim_key: string; dim_desc: string; question: string }>>([])
const [factAnswers, setFactAnswers] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [factInputs, setFactInputs] = useState<Record<string, string>>({})
/** 事实确认区:追问完成后展示,允许用户修改再继续 */
const [factConfirm, setFactConfirm] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [factConfirmEditing, setFactConfirmEditing] = useState<Record<string, string>>({})
const [profileFacts, setProfileFacts] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [elementTemplate, setElementTemplate] = useState<Record<string, unknown> | null>(null)
const [elementFiling, setElementFiling] = useState<{ cause_of_action: string; elements: Array<{ key: string; label: string; value: string }>; claims: string[]; filing_text: string } | null>(null)
const [generatingFiling, setGeneratingFiling] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
/** 待切换的目标模式(销毁确认后切换) */
const [pendingMode, setPendingMode] = useState<SessionMode | null>(null)
/** 当前分析中的用户问题(避免从 messages 闭包取旧值) */
const currentQuestionRef = useRef('')
/** 验证 session 是否有效,失效则创建新 session */
const ensureSession = async (): Promise<string> => {
if (sid) {
try {
await api.sessionAlive(sid)
// session 存活但 matterId 可能丢失(页面刷新后),通过 session_id 恢复
if (!matterId) {
api.getMatterBySession(sid)
.then(m => setMatterId(m.id))
.catch(() => {/* 没有关联案卡,不影响 */})
}
return sid
} catch {
// session 已失效(410/404,如后端重启后内存 session 丢失),清掉旧 sid
setSid('')
}
}
const res = await api.createSession(mode)
const newSid = res.session_id
setSid(newSid)
toast.success(`已创建${modes.find((m) => m.value === mode)?.label}会话`)
// 2.0 新增:创建案卡(失败不影响主流程)
api.createMatter({ scene, title: currentQuestionRef.current.substring(0, 30) || topic || '新事项', session_id: newSid })
.then(m => setMatterId(m.id))
.catch(() => {/* 案卡创建失败不影响主流程 */})
return newSid
}
/** 流式输出 AI 分析结论(事实已充分后调用) */
const streamAnalysis = async (currentSid: string, userQuestion: string, answers: Array<{ dim_key: string; dim_desc: string; answer: string }>) => {
// 用 ref 保存 assistant 消息 index,避免闭包旧值
const assistantIndexRef = { current: 0 }
setMessages((prev) => {
assistantIndexRef.current = prev.length
return [...prev, { role: 'assistant', content: '', streaming: true }]
})
let accumulated = ''
let riskCard: AskResponse['risk'] | undefined
let doneData: Partial<AskResponse> = {}
await api.askStream(currentSid, userQuestion, scene, emotionMode, {
onRisk: (risk) => {
riskCard = risk
setMessages((prev) => {
const next = [...prev]
const idx = assistantIndexRef.current
if (next[idx]) {
next[idx] = { ...next[idx], risk: showRiskCard ? risk : undefined }
}
return next
})
},
onDelta: (content) => {
accumulated += content
setMessages((prev) => {
const next = [...prev]
const idx = assistantIndexRef.current
if (next[idx]) {
next[idx] = { ...next[idx], content: accumulated }
}
return next
})
},
onDone: (data) => {
doneData = data
setMessages((prev) => {
const next = [...prev]
const idx = assistantIndexRef.current
if (next[idx]) {
next[idx] = {
...next[idx],
content: accumulated || doneData.answer || riskCard?.conclusion || '',
streaming: false,
answer: doneData.answer,
model: doneData.model,
inferenceLocation: doneData.inference_location,
fallbackUsed: doneData.fallback_used,
ragContext: doneData.rag_context,
}
}
return next
})
},
onError: (error) => {
setMessages((prev) => {
const next = [...prev]
const idx = assistantIndexRef.current
if (next[idx]) {
next[idx] = { ...next[idx], content: accumulated || `⚠️ ${error}`, streaming: false }
}
return next
})
toast.error(error)
},
}, answers.length > 0 ? answers : undefined)
}
/** 换一批:优先从当天热点池轮换,池用完再调 AI 生成(注入用户画像) */
const handleRefreshSamples = async () => {
// 池里还有未展示的,直接取下一批
const nextIndex = poolIndex + BATCH_SIZE
if (nextIndex < dailyPool.length) {
setPoolIndex(nextIndex)
setDynamicSamples(dailyPool.slice(nextIndex, nextIndex + BATCH_SIZE))
return
}
// 池用完,调 AI 生成新问题
setRefreshing(true)
try {
const phone = getPhone() || undefined
const res = await api.generateSamples(scene, topic, dynamicSamples, phone)
if (res.samples?.length > 0) {
// AI 生成的新问题加入池并显示
const newPool = [...dailyPool, ...res.samples]
const newIndex = dailyPool.length
setDailyPool(newPool)
setPoolIndex(newIndex)
setDynamicSamples(res.samples)
}
} catch {
toast.error('换一批失败,请稍后重试')
} finally {
setRefreshing(false)
}
}
const handleAsk = async () => {
if (!question.trim()) {
toast.error('请输入问题')
return
}
const userQuestion = question.trim()
setLoading(true)
setQuestion('')
setFactAnswers([])
setFactQuestions([])
setFactInputs({})
setFactConfirm([])
setProfileFacts([])
currentQuestionRef.current = userQuestion
// 添加用户消息
setMessages((prev) => [...prev, { role: 'user', content: userQuestion }])
try {
const currentSid = await ensureSession()
// 第1步:分析问题,判断是否需要追问
setAnalyzing(true)
const analysis = await api.askAnalyze(currentSid, userQuestion, scene, emotionMode, undefined, topic)
setAnalyzing(false)
// 保存画像带入事实
const pf = analysis.profile_facts || []
setProfileFacts(pf)
setElementTemplate(analysis.element_template || null)
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
// 需要追问:显示追问问题
setFactQuestions(analysis.questions)
// 2.0 新增:更新案卡状态为 asking_facts
if (matterId) {
try { await api.updateMatterStatus(matterId, 'asking_facts') } catch {/* 失败不影响主流程 */}
}
// 不继续流式输出,等待用户回答
return
}
// 不需要追问,进入确认区(带入内容 + 无追问内容)
setFactConfirm(pf)
setFactConfirmEditing(Object.fromEntries(pf.map((f) => [f.dim_key, f.answer])))
} catch (e) {
setAnalyzing(false)
toast.error(e instanceof Error ? e.message : '提问失败')
} finally {
setLoading(false)
}
}
/** 用户提交追问回答 */
const handleFactSubmit = async () => {
// 收集回答
const newAnswers = factQuestions
.map((q) => ({
dim_key: q.dim_key,
dim_desc: q.dim_desc,
answer: factInputs[q.dim_key]?.trim() || '',
}))
.filter((a) => a.answer)
if (newAnswers.length === 0) {
toast.error('请至少回答一个问题')
return
}
const allAnswers = [...factAnswers, ...newAnswers]
setFactAnswers(allAnswers)
setLoading(true)
// 添加用户回答到消息列表
const answerText = newAnswers.map((a) => `**${a.dim_desc}**: ${a.answer}`).join('\n')
setMessages((prev) => [...prev, { role: 'user', content: answerText, isFactAnswer: true }])
try {
const currentSid = sid || (await ensureSession())
setAnalyzing(true)
const analysis = await api.askAnalyze(
currentSid,
currentQuestionRef.current,
scene,
emotionMode,
allAnswers,
topic,
)
setAnalyzing(false)
// 更新画像带入事实
const pf = analysis.profile_facts || []
setProfileFacts(pf)
setElementTemplate(analysis.element_template || null)
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
// 还需追问
setFactQuestions(analysis.questions)
setFactInputs({})
return
}
// 2.0 新增:事实提交后返回 pending_confirm,更新案卡状态
if (matterId) {
try { await api.updateMatterStatus(matterId, 'pending_confirm') } catch {/* 失败不影响主流程 */}
}
// 事实充分,进入确认区(带入内容 + 追问内容)
setFactQuestions([])
setFactInputs({})
// 合并:画像带入事实在前,追问回答在后(去重 by dim_key)
const mergedMap = new Map<string, { dim_key: string; dim_desc: string; answer: string }>()
for (const f of pf) mergedMap.set(f.dim_key, f)
for (const a of allAnswers) mergedMap.set(a.dim_key, a)
const merged = Array.from(mergedMap.values())
setFactConfirm(merged)
setFactConfirmEditing(Object.fromEntries(merged.map((a) => [a.dim_key, a.answer])))
} catch (e) {
setAnalyzing(false)
toast.error(e instanceof Error ? e.message : '追问失败')
} finally {
setLoading(false)
}
}
const handleDestroy = async () => {
if (!sid) return
setDestroying(true)
try {
await api.destroy(sid)
setSid(null)
setMatterId(null)
setMessages([])
setFactAnswers([])
setFactQuestions([])
setFactInputs({})
// 如果是模式切换触发的销毁,切换到目标模式
if (pendingMode) {
setMode(pendingMode)
setPendingMode(null)
toast.success(`已切换到${modes.find((m) => m.value === pendingMode)?.label}模式`)
} else {
toast.success('会话已销毁,所有内容已清除')
}
} catch (e) {
toast.error(e instanceof Error ? e.message : '销毁失败')
} finally {
setDestroying(false)
setConfirmDestroy(false)
}
}
/** 触发问专家:提取原始问题和追问补充,打开专家咨询弹窗 */
const handleEscalate = () => {
const originalQuestion = messages.find((m) => m.role === 'user' && !m.isFactAnswer)
const factAnswers = messages.filter((m) => m.isFactAnswer)
const supplement = factAnswers
.map((m) => m.content.replace(/\*\*/g, ''))
.join('\n')
openExpertConsult(originalQuestion?.content || '', supplement)
}
/** 打开专家咨询弹窗:加载该场景分类的专家 */
const openExpertConsult = async (topic: string, supplement: string) => {
setConsultQuestion(topic)
setConsultSupplement(supplement)
setExpertConsultOpen(true)
setSelectedExpert(null)
try {
// 只加载已确认服务(active)的专家
const res = await api.myServices()
const activeServices = res.results.filter(s => s.status === 'active')
setExpertList(activeServices)
// 2.0 新增:如果有 location state 传入 expertId,默认选中
if (expertId) {
const matched = activeServices.find(s => s.expert_id === expertId)
if (matched) setSelectedExpert(matched)
}
} catch {
toast.error('加载服务专家失败')
setExpertList([])
}
}
/** 发送咨询给专家 */
const handleConsult = async () => {
if (!selectedExpert) {
toast.error('请选择一位专家')
return
}
setConsulting(true)
try {
// 获取最近的 AI 回复作为上下文
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant' && m.answer)
const context = lastAssistant?.answer || ''
// 咨询内容 = 问题 + 补充内容(直接换行拼接)
const fullQuestion = consultSupplement.trim()
? `${consultQuestion}\n\n${consultSupplement.trim()}`
: consultQuestion
await api.createConsultation({
expert_id: selectedExpert.expert_id,
topic: consultQuestion.slice(0, 200),
context: context.slice(0, 500),
question: fullQuestion,
})
toast.success(`已发送给${selectedExpert.expert_name},请到专家库查看回复`)
// 2.0 新增:问专家后更新案卡状态为 pending_expert
if (matterId) {
try { await api.updateMatterStatus(matterId, 'pending_expert') } catch {/* 失败不影响主流程 */}
}
setExpertConsultOpen(false)
setSelectedExpert(null)
setConsultQuestion('')
setConsultSupplement('')
} catch (e) {
toast.error(e instanceof Error ? e.message : '发送失败,请先选定专家')
} finally {
setConsulting(false)
}
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !sid) {
if (!sid) toast.error('请先创建会话再上传文件')
return
}
try {
const res = await api.uploadFile(sid, file)
toast.success(`文件已进入临时会话区: ${res.filename}`)
setMessages((prev) => [
...prev,
{ role: 'user', content: `[上传文件] ${res.filename}` },
{ role: 'assistant', content: res.message + (res.text_preview ? `\n\n预览:\n${res.text_preview}` : '') },
])
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败')
} finally {
if (fileRef.current) fileRef.current.value = ''
}
}
const switchMode = (m: SessionMode) => {
if (sid) {
setPendingMode(m)
setConfirmDestroy(true)
return
}
setMode(m)
}
/** 2.0 新增:闭环案卡 */
const handleCloseMatter = async () => {
if (!matterId) return
setClosing(true)
try {
await api.closeMatter(matterId, closeResult, closeNote)
toast.success('事项已闭环')
setShowCloseDialog(false)
setMatterId(null)
setCloseNote('')
setCloseResult('顺利')
} catch (e) {
toast.error(e instanceof Error ? e.message : '闭环失败')
} finally {
setClosing(false)
}
}
/** 2.0 新增:分享风险卡(复制到剪贴板) */
const copyRisk = () => {
const topMsg = messages.find(m => m.risk)
if (!topMsg?.risk) return
const text = riskCardToMarkdown(topMsg.risk, topMsg.answer)
navigator.clipboard.writeText(text).then(() => {
toast.success('风险卡已复制到剪贴板')
}).catch(() => {
toast.error('复制失败')
})
}
/** 2.0 新增:导出风险卡(下载 Markdown) */
const exportRisk = () => {
const topMsg = messages.find(m => m.risk)
if (!topMsg?.risk) return
const text = riskCardToMarkdown(topMsg.risk, topMsg.answer)
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `风险结论_${Date.now()}.md`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出 Markdown 文件')
}
/** 2.0 新增:打开起诉状三步向导 */
const openFilingWizard = async () => {
const confirmedAnswers = factConfirm.map((f) => ({
dim_key: f.dim_key,
dim_desc: f.dim_desc,
answer: (factConfirmEditing[f.dim_key] ?? '').trim() || f.answer,
}))
setGeneratingFiling(true)
try {
const currentSid = sid || (await ensureSession())
const causeOfAction = (elementTemplate as Record<string, unknown>)?.cause_of_action as string || '劳动争议纠纷'
const res = await api.elementFiling(currentSid, currentQuestionRef.current, causeOfAction, confirmedAnswers, elementTemplate || undefined)
setFilingCause(res.cause_of_action)
setFilingElements(res.elements)
setFilingClaims(res.claims)
setFilingText(res.filing_text)
setFilingStep(1)
setFilingWizardOpen(true)
} catch (e) {
toast.error(e instanceof Error ? e.message : '要素式起诉状生成失败')
} finally {
setGeneratingFiling(false)
}
}
/** 2.0 新增:起诉状向导-导出/发送/复制 */
const handleFilingExport = () => {
const blob = new Blob([filingText], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `起诉状_${filingCause}_${Date.now()}.md`
a.click()
URL.revokeObjectURL(url)
toast.success('起诉状已导出')
}
const handleFilingSendToExpert = () => {
setFilingWizardOpen(false)
openExpertConsult(`要素式起诉状·${filingCause}`, filingText)
}
const handleFilingCopy = () => {
navigator.clipboard.writeText(filingText).then(() => {
toast.success('已复制到剪贴板')
}).catch(() => {
toast.error('复制失败')
})
}
const currentMode = modes.find((m) => m.value === mode)!
// 2.0 新增:风险卡置顶 - 取第一条带 risk 的消息
const topRiskMsg = messages.find(m => m.risk && !m.streaming)
const topRiskCard = topRiskMsg?.risk
const analysisComplete = !!topRiskMsg && !topRiskMsg.streaming
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-base">对话</CardTitle>
{/* 模式选择:紧凑 segmented control */}
<div className="flex flex-wrap items-center gap-0.5 rounded-md border bg-muted/50 p-0.5">
{modes.map((m) => (
<button
key={m.value}
onClick={() => switchMode(m.value)}
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors',
mode === m.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={m.desc}
>
<m.Icon className="h-3 w-3" aria-hidden />
{m.label}
</button>
))}
</div>
</div>
<Dialog open={confirmDestroy} onOpenChange={setConfirmDestroy}>
<DialogTrigger asChild>
<Button
variant={mode === 'ephemeral' ? 'destructive' : 'outline'}
size="sm"
disabled={!sid || destroying}
>
{destroying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Flame className="h-3.5 w-3.5" />
)}
{mode === 'ephemeral' ? '销毁会话' : '隐私急停'}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{pendingMode ? `切换到${modes.find((m) => m.value === pendingMode)?.label}模式?` : '确认销毁当前会话?'}
</DialogTitle>
<DialogDescription>
{pendingMode
? `切换模式将销毁当前会话,切换到${modes.find((m) => m.value === pendingMode)?.label}模式。`
: '销毁后将立即清除会话密钥和所有消息内容,不可恢复。'
}
若涉及正式合同、诉讼或劳动争议,可能需要依法留档。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => { setConfirmDestroy(false); setPendingMode(null) }}>
取消
</Button>
<Button variant="destructive" onClick={handleDestroy}>
确认销毁
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* 场景特定选择器(如情绪模式) */}
{modeSelector}
{/* 2.0 新增:location state 传入 expertId 时显示提示 */}
{expertId && (
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-1.5 text-xs text-primary">
<Users className="h-3.5 w-3.5" aria-hidden />
咨询{selectedExpert?.expert_name || '专家'}{lastTopic ? `·上次话题:${lastTopic}` : ''}
</div>
)}
{sid && (
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-1.5 text-xs text-muted-foreground">
<currentMode.Icon className="h-3.5 w-3.5" aria-hidden />
{currentMode.label}会话已建立
</div>
)}
{/* 2.0 新增:风险卡置顶(sticky,仅显示结论摘要 + 分享/导出) */}
{topRiskCard && (
<div className="sticky top-0 z-10 bg-card border-b border-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">风险结论</span>
<Badge variant="outline" className={cn('text-xs', topRiskCard.level === 'red' ? 'text-red-600' : topRiskCard.level === 'orange' ? 'text-orange-600' : topRiskCard.level === 'amber' ? 'text-amber-600' : 'text-green-600')}>
{topRiskCard.conclusion}
</Badge>
</div>
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={copyRisk}>分享</Button>
<Button size="sm" variant="ghost" onClick={exportRisk}>导出</Button>
</div>
</div>
</div>
)}
{/* 对话区(可滚动,输入区固定在下方) */}
{messages.length === 0 ? (
<div className="py-2">
<div className="flex flex-wrap items-center gap-2">
{dynamicSamples.map((s) => (
<Button key={s} variant="outline" size="sm" onClick={() => {
setQuestion(s)
api.recordSampleClick(scene, s).catch(() => {})
}} className="text-xs">
{s}
</Button>
))}
<Button
variant="ghost"
size="sm"
onClick={handleRefreshSamples}
disabled={refreshing}
className="text-xs text-muted-foreground"
>
{refreshing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
换一批
</Button>
</div>
</div>
) : (
<div className="max-h-[50vh] space-y-4 overflow-y-auto pr-1 scrollbar-none">
{messages.map((msg, i) => (
<div
key={i}
className={cn('flex gap-3', msg.role === 'user' ? 'justify-end' : 'justify-start')}
>
<div
className={cn(
'rounded-lg p-3 text-sm',
msg.role === 'user'
? 'max-w-[80%] bg-primary text-primary-foreground'
: 'max-w-[95%] bg-muted sm:max-w-[85%]',
)}
>
{msg.risk ? (
<RiskCardView
card={msg.risk}
answer={msg.answer || msg.content}
model={msg.model}
inferenceLocation={msg.inferenceLocation}
fallbackUsed={msg.fallbackUsed}
ragContext={msg.ragContext}
streaming={msg.streaming}
onEscalate={handleEscalate}
/>
) : msg.role === 'user' && !msg.isFactAnswer ? (
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
) : msg.isFactAnswer ? (
<Markdown
content={msg.content}
streaming={msg.streaming}
className="prose-headings:text-primary-foreground prose-p:text-primary-foreground prose-li:text-primary-foreground prose-strong:text-primary-foreground"
/>
) : (
<div>
<Markdown content={msg.content} streaming={msg.streaming} />
{/* 参考来源 tag + 问专家按钮(流式完成后显示) */}
{!msg.streaming && (
<>
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-border/30 pt-2">
<span className="text-[10px] text-muted-foreground">参考来源:</span>
{msg.ragContext && msg.ragContext.length > 0 ? (
<>
<BookOpen className="h-3 w-3 text-muted-foreground" aria-hidden />
{msg.ragContext.map((doc, i) => (
<span key={i} className="flex items-center gap-1 text-[10px]">
<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>
)}
</span>
))}
</>
) : (
<>
<Sparkles className="h-3 w-3 text-muted-foreground" aria-hidden />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</>
)}
</div>
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleEscalate}>
<Users className="h-3.5 w-3.5" /> 问专家
</Button>
</div>
</>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
{/* 事实追问区 */}
{factQuestions.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-amber-900">
<HelpCircle className="h-4 w-4" aria-hidden />
请补充以下信息,以便更准确地分析
</div>
{factQuestions.map((q) => (
<div key={q.dim_key} className="space-y-1.5">
<Label className="text-xs text-amber-800">{q.question}</Label>
<Input
value={factInputs[q.dim_key] || ''}
onChange={(e) => setFactInputs((prev) => ({ ...prev, [q.dim_key]: e.target.value }))}
placeholder="请输入…"
className="bg-white"
/>
</div>
))}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// 跳过追问,进入确认区(带入内容 + 已有追问回答)
setFactQuestions([])
setFactInputs({})
const mergedMap = new Map<string, { dim_key: string; dim_desc: string; answer: string }>()
for (const f of profileFacts) mergedMap.set(f.dim_key, f)
for (const a of factAnswers) mergedMap.set(a.dim_key, a)
const merged = Array.from(mergedMap.values())
setFactConfirm(merged)
setFactConfirmEditing(Object.fromEntries(merged.map((a) => [a.dim_key, a.answer])))
}}
disabled={loading}
>
跳过,直接分析
</Button>
<Button onClick={handleFactSubmit} disabled={loading} size="sm">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
提交补充
</Button>
</div>
</div>
)}
{/* 确认事实区:带入内容 + 追问内容,允许用户修改再继续 */}
{factConfirm.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-blue-900">
<CheckCircle className="h-4 w-4" aria-hidden />
确认事实
</div>
<p className="text-xs text-blue-700">请核对以下信息,可直接修改后继续分析。确认后将更新你的用户画像。</p>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{factConfirm.map((f) => {
const isProfile = profileFacts.some((p) => p.dim_key === f.dim_key)
const rawValue = factConfirmEditing[f.dim_key] ?? f.answer
const displayValue = isProfile ? translateProfileValue(rawValue) : rawValue
return (
<div key={f.dim_key} className="flex items-center gap-1 rounded border border-blue-200 bg-white px-1.5 py-0.5">
<Badge
variant={isProfile ? 'secondary' : 'outline'}
className="shrink-0 text-[10px] font-medium px-1 py-0"
>
{translateDimLabel(f.dim_desc)}
</Badge>
<input
type="text"
value={displayValue}
onChange={(e) => setFactConfirmEditing((prev) => ({ ...prev, [f.dim_key]: e.target.value }))}
className="min-w-0 flex-1 border-none bg-transparent px-0.5 py-0 text-xs text-foreground focus:outline-none"
/>
</div>
)
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// 取消确认,回到追问
setFactConfirm([])
setFactConfirmEditing({})
}}
disabled={loading}
>
返回修改
</Button>
<div className="flex gap-2">
<Button
onClick={async () => {
// 确认事实,更新用户画像,再开始流式分析
const confirmedAnswers = factConfirm.map((f) => ({
dim_key: f.dim_key,
dim_desc: f.dim_desc,
answer: (factConfirmEditing[f.dim_key] ?? '').trim() || f.answer,
}))
setFactAnswers(confirmedAnswers)
setFactConfirm([])
setFactConfirmEditing({})
setFactQuestions([]) // 清空追问区,避免分析完成后旧追问仍显示
setFactInputs({})
setLoading(true)
const originalQuestion = currentQuestionRef.current
try {
const currentSid = sid || (await ensureSession())
// 更新用户画像(将确认的事实写回画像)
const profileUpdate: Record<string, string> = {}
const keyFactsUpdate: Record<string, string> = {}
for (const a of confirmedAnswers) {
if (['industry', 'company_size', 'role', 'risk_tolerance', 'decision_style'].includes(a.dim_key)) {
profileUpdate[a.dim_key] = a.answer
} else if (a.dim_key.startsWith('fact_')) {
keyFactsUpdate[a.dim_desc] = a.answer
}
}
if (Object.keys(profileUpdate).length > 0 || Object.keys(keyFactsUpdate).length > 0) {
api.updateProfile({ ...profileUpdate, key_facts: keyFactsUpdate }).catch(() => {})
}
// 2.0 新增:用户确认分析时,更新案卡状态为 analyzing
if (matterId) {
try { await api.updateMatterStatus(matterId, 'analyzing') } catch {/* 失败不影响主流程 */}
}
await streamAnalysis(currentSid, originalQuestion, confirmedAnswers)
} catch (e) {
toast.error(e instanceof Error ? e.message : '分析失败')
} finally {
setLoading(false)
}
}}
disabled={loading}
size="sm"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
确认并分析
</Button>
{scene === 'legal' && elementTemplate && (
<Button
variant="outline"
onClick={openFilingWizard}
disabled={loading || generatingFiling}
size="sm"
>
{generatingFiling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
生成要素式起诉状
</Button>
)}
</div>
</div>
</div>
)}
{/* 要素化立案结果(兼容旧版直接展示) */}
{elementFiling && !filingWizardOpen && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-green-900">
<FileText className="h-4 w-4" aria-hidden />
要素式起诉状 · {elementFiling.cause_of_action}
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-green-800">诉讼请求</p>
<div className="flex flex-wrap gap-1">
{elementFiling.claims.map((c, i) => (
<Badge key={i} variant="outline" className="text-[10px]">☐ {c}</Badge>
))}
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-green-800">要素清单</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{elementFiling.elements.map((e, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{e.label}</span>
<span className="font-medium">{e.value}</span>
</div>
))}
</div>
</div>
<details className="text-xs">
<summary className="cursor-pointer text-green-800">查看完整起诉状文本</summary>
<pre className="mt-2 whitespace-pre-wrap rounded bg-white p-3 text-xs text-foreground border">{elementFiling.filing_text}</pre>
</details>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(elementFiling.filing_text)
toast.success('已复制到剪贴板')
}}
>
复制全文
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setElementFiling(null)}
>
关闭
</Button>
</div>
</div>
)}
{/* 分析中提示 */}
{analyzing && (
<div className="flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
正在分析问题…
</div>
)}
{/* 恢复历史中提示 */}
{restoringHistory && (
<div className="flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
正在恢复之前的对话…
</div>
)}
{/* 输入区 */}
<div className="space-y-2 border-t pt-4">
<Label htmlFor="question">你的问题</Label>
<Textarea
id="question"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleAsk()
}
}}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
{enableUpload && (
<>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={handleUpload}
accept=".txt,.md,.csv,.json"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={!sid}
>
<Upload className="h-3.5 w-3.5" /> 上传文件
</Button>
</>
)}
<span className="hidden text-xs text-muted-foreground sm:inline">⌘/Ctrl + Enter 发送</span>
<Button onClick={handleAsk} disabled={loading || !question.trim()} size="sm">
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
分析
</Button>
</div>
</div>
{/* 卡片底部脚注 */}
{footer && (
<p className="border-t pt-2 text-center text-xs text-muted-foreground">
{footer}
</p>
)}
</CardContent>
</Card>
{/* 2.0 新增:闭环按钮(分析完成后显示) */}
{matterId && analysisComplete && (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setShowCloseDialog(true)}>
<CheckCircle className="mr-2 h-4 w-4" /> 闭环此事项
</Button>
</div>
)}
{/* 2.0 新增:保密模式恢复密码验证弹窗 */}
<Dialog open={restoreVerifyOpen} onOpenChange={setRestoreVerifyOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-4 w-4 text-primary" /> 验证密码以恢复保密会话
</DialogTitle>
<DialogDescription>
这是保密模式会话,需要验证登录密码后才能查看历史内容。
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Input
type="password"
value={restorePassword}
onChange={(e) => setRestorePassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRestoreVerify()}
placeholder="请输入登录密码"
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => { setRestoreVerifyOpen(false); setRestorePassword('') }}>
取消
</Button>
<Button size="sm" onClick={handleRestoreVerify} disabled={verifyLoading || !restorePassword.trim()}>
{verifyLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lock className="h-3.5 w-3.5" />}
验证并恢复
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 2.0 新增:闭环弹窗 */}
<Dialog open={showCloseDialog} onOpenChange={setShowCloseDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-primary" /> 闭环此事项
</DialogTitle>
<DialogDescription>
记录执行结果,完成事项闭环。闭环后可在案卡看板查看历史。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-2 block text-sm font-medium">执行结果</Label>
<div className="flex gap-2">
{(['顺利', '受阻', '未执行'] as const).map((r) => (
<button
key={r}
onClick={() => setCloseResult(r)}
className={cn(
'rounded-md border px-3 py-1.5 text-sm transition-colors',
closeResult === r
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-muted-foreground hover:bg-muted/40',
)}
>
{r}
</button>
))}
</div>
</div>
<div>
<Label htmlFor="close-note" className="mb-1.5 block text-sm font-medium">
备注 <span className="text-xs font-normal text-muted-foreground/70">(可选)</span>
</Label>
<Textarea
id="close-note"
value={closeNote}
onChange={(e) => setCloseNote(e.target.value)}
placeholder="记录执行过程中的关键信息…"
className="min-h-[80px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCloseDialog(false)}>取消</Button>
<Button onClick={handleCloseMatter} disabled={closing}>
{closing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
确认闭环
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 2.0 新增:起诉状三步向导 */}
<Dialog open={filingWizardOpen} onOpenChange={setFilingWizardOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" /> 要素式起诉状 · {filingCause}
</DialogTitle>
<DialogDescription>
三步完成起诉状:预览 → 编辑 → 导出
</DialogDescription>
</DialogHeader>
{/* 步骤指示器 */}
<div className="flex items-center justify-center gap-2 py-2">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center gap-2">
<div className={cn(
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium',
filingStep >= step ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground',
)}>
{step}
</div>
{step < 3 && <ChevronRight className="h-3 w-3 text-muted-foreground" />}
</div>
))}
<span className="ml-2 text-xs text-muted-foreground">{filingStep}/3</span>
</div>
{/* 步骤1:预览(只读) */}
{filingStep === 1 && (
<div className="space-y-3">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">诉讼请求</p>
<div className="flex flex-wrap gap-1">
{filingClaims.map((c, i) => (
<Badge key={i} variant="outline" className="text-[10px]">☐ {c}</Badge>
))}
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">要素清单(只读)</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{filingElements.map((e, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{e.label}</span>
<span className="font-medium">{e.value}</span>
</div>
))}
</div>
</div>
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">查看完整起诉状文本</summary>
<pre className="mt-2 whitespace-pre-wrap rounded bg-muted/50 p-3 text-xs text-foreground border max-h-60 overflow-y-auto">{filingText}</pre>
</details>
</div>
)}
{/* 步骤2:编辑(可修改要素值) */}
{filingStep === 2 && (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">可直接修改要素值,修改后点击下一步生成新文本。</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{filingElements.map((e, i) => (
<div key={e.key} className="space-y-1">
<Label className="text-[10px] text-muted-foreground">{e.label}</Label>
<Input
value={e.value}
onChange={(ev) => setFilingElements(prev => prev.map((item, idx) => idx === i ? { ...item, value: ev.target.value } : item))}
className="text-xs"
/>
</div>
))}
</div>
</div>
)}
{/* 步骤3:导出 */}
{filingStep === 3 && (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">选择导出方式:</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleFilingExport}>
<Download className="h-3.5 w-3.5" /> 下载 Markdown
</Button>
<Button variant="outline" size="sm" onClick={handleFilingSendToExpert}>
<Users className="h-3.5 w-3.5" /> 发送给专家
</Button>
<Button variant="outline" size="sm" onClick={handleFilingCopy}>
<FileText className="h-3.5 w-3.5" /> 复制全文
</Button>
</div>
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">预览最终文本</summary>
<pre className="mt-2 whitespace-pre-wrap rounded bg-muted/50 p-3 text-xs text-foreground border max-h-60 overflow-y-auto">{filingText}</pre>
</details>
</div>
)}
<DialogFooter>
{filingStep > 1 && (
<Button variant="outline" onClick={() => setFilingStep(filingStep - 1)}>
<ChevronLeft className="h-3.5 w-3.5" /> 上一步
</Button>
)}
{filingStep < 3 ? (
<Button onClick={() => setFilingStep(filingStep + 1)}>
下一步 <ChevronRight className="h-3.5 w-3.5" />
</Button>
) : (
<Button onClick={() => { setFilingWizardOpen(false); setElementFiling(null) }}>
完成
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 专家咨询弹窗 */}
<Dialog open={expertConsultOpen} onOpenChange={setExpertConsultOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" /> 发给专家咨询
</DialogTitle>
<DialogDescription>
将此话题发给已确认服务关系的专家,获取人工专业意见。仅显示已同意为你服务的专家。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 已确认服务的专家列表 */}
<div>
<Label className="mb-2 flex items-center gap-1.5 text-sm font-medium">
<Check className="h-3.5 w-3.5 text-emerald-500" />
选择专家
</Label>
{expertList.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-6 text-center">
<Users className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">暂无已确认服务的专家</p>
<p className="text-xs text-muted-foreground/70">请先到专家库选定专家并等待确认</p>
</div>
) : (
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
{expertList.map((s) => (
<button
key={s.service_id}
className={cn(
'flex w-full items-center gap-3 rounded-lg border p-2.5 text-left transition',
selectedExpert?.service_id === s.service_id
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:bg-muted/40',
)}
onClick={() => setSelectedExpert(s)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-medium text-primary">
{s.expert_name?.[0] || '?'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{s.expert_name}</p>
<p className="truncate text-xs text-muted-foreground">{s.expert_title} · {s.expert_bio}</p>
</div>
<Badge variant="secondary" className="shrink-0 text-xs">服务中</Badge>
</button>
))}
</div>
)}
</div>
{/* 咨询问题 */}
<div>
<Label htmlFor="consult-q" className="mb-1.5 flex items-center gap-1.5 text-sm font-medium">
<MessageSquare className="h-3.5 w-3.5 text-primary" />
咨询问题
</Label>
<Textarea
id="consult-q"
value={consultQuestion}
onChange={(e) => setConsultQuestion(e.target.value)}
placeholder="描述你想咨询的具体问题…"
className="min-h-[56px] resize-none"
/>
</div>
{/* 补充内容 */}
<div>
<Label htmlFor="consult-sup" className="mb-1.5 flex items-center gap-1.5 text-sm font-medium">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
补充内容
<span className="text-xs font-normal text-muted-foreground/70">(可选)</span>
</Label>
<Textarea
id="consult-sup"
value={consultSupplement}
onChange={(e) => setConsultSupplement(e.target.value)}
placeholder="补充背景信息、具体细节或期望专家关注的重点…"
className="min-h-[100px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setExpertConsultOpen(false)}>取消</Button>
<Button onClick={handleConsult} disabled={consulting || !selectedExpert || !consultQuestion.trim()}>
{consulting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
发送咨询
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}