数据库: - daily_topics表:每天每场景一条热点问题记录 - sample_clicks表:记录用户点击示例问题行为 后端: - DailyTopicStore: CRUD + 埋点 + 点击统计 - generate_daily_topics: AI生成4场景各25-30个问题 - 定时任务:每天凌晨3点自动生成(后台线程轮询) - GET /topics/daily: 获取当天热点(公开) - POST /topics/click: 点击埋点 - GET /admin/daily-topics: 管理端查看 - POST /admin/daily-topics: 管理员手动更新 - POST /admin/daily-topics/generate: 手动触发AI生成 - GET /admin/sample-clicks: 点击统计 前端: - ChatPanel挂载时取当天热点,无则用硬编码兜底 - 点击示例问题时记录埋点 - AdminDailyTopics管理页面:查看/编辑/手动生成 - 路由 /admin/daily-topics + 导航配置 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1711 lines
65 KiB
TypeScript
1711 lines
65 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 { 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)
|
|
|
|
// ===== 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) {
|
|
setDynamicSamples(res.questions)
|
|
}
|
|
})
|
|
.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 () => {
|
|
setRefreshing(true)
|
|
try {
|
|
const res = await api.generateSamples(scene, topic, dynamicSamples)
|
|
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>
|
|
</>
|
|
)
|
|
}
|