import * as React from 'react' import { AlertCircle, Activity, ArrowRight, Ban, Bot, Check, CheckCircle2, File, FileImage, FileSpreadsheet, FileText, LoaderCircle, MessageSquareMore, Presentation, RotateCcw, Send, ShieldCheck, Sparkles, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { AppField } from '@/components/app/AppField' import { AppPage } from '@/components/app/AppPage' import { AppPageHeader } from '@/components/app/AppPageHeader' import { AppSection } from '@/components/app/AppSection' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' import { Bubble, BubbleContent } from '@/components/ui/bubble' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { FieldGroup } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { Message, MessageContent, MessageGroup } from '@/components/ui/message' import { MessageScroller, MessageScrollerContent, MessageScrollerItem, MessageScrollerViewport } from '@/components/ui/message-scroller' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { useIsMobile } from '@/hooks/use-mobile' import { cn } from '@/lib/utils' import { createTicket } from './api' import { analyzeTicketDraft, answerTicketDraft, cancelTicketDraftAnalysis, confirmTicketDraft, createTicketDraft, deleteTicketDraftMaterial, getTicketDraft, getTicketDraftAvailability, listTicketDrafts, rejectTicketDraft, subscribeAgentRunEvents, updateTicketDraft, uploadTicketDraftMaterial } from './draft-api' import type { AgentRunEvent, TicketDraft, TicketDraftAvailability, TicketDraftMaterial } from './draft-types' import { TICKET_CATEGORIES } from './ticketCategories' import type { TicketInput } from './types' const initialForm: TicketInput = { title: '', description: '', category_code: 'business_issue', priority: 'medium', problem_performance: '', problem_object: '', submitter_name: '', submitter_org: '', impact_scope: '', environment: '', related_system: '' } const ENVIRONMENT_OPTIONS = ['生产环境', '测试环境', '预发布环境', '本地环境'] const FIELD_LABELS: Record = { title: '工单标题', description: '问题描述', category_code: '问题分类', priority: '优先级', problem_performance: '问题表现', problem_object: '问题对象', submitter_name: '提出人', submitter_org: '提出机构', impact_scope: '影响范围', environment: '环境', related_system: '关联系统' } type ConversationItem = { role: 'assistant' | 'user'; content: string } function mergeForm(fields: Partial): TicketInput { return { ...initialForm, ...fields } } function formatFileSize(size: number) { if (size < 1024) return `${size} B` if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB` return `${(size / 1024 / 1024).toFixed(1)} MB` } function MaterialIcon({ material }: { material: Pick }) { if (material.material_type === 'image') return if (material.material_type === 'spreadsheet') return if (material.material_type === 'presentation') return if (material.material_type === 'document' || material.material_type === 'text' || material.material_type === 'log') return return } function MaterialStatus({ material }: { material: TicketDraftMaterial }) { if (material.scan_status === 'infected') return 已拦截 if (material.scan_status === 'failed') return 扫描失败 if (material.parse_status === 'failed' || material.parse_status === 'blocked') return 解析失败 if (material.scan_status === 'clean' && ['parsed', 'not_applicable'].includes(material.parse_status)) return 可用 return 处理中 } function DraftSpine({ draft }: { draft: TicketDraft | null }) { const active = draft?.status === 'analyzing' ? 1 : draft?.status === 'needs_input' || draft?.status === 'ready' || draft?.status === 'failed' ? 2 : draft?.status === 'ticket_created' || draft?.status === 'confirmed' ? 3 : 0 return (
{['提交材料', '智能解析', '补全事实', '正式建单'].map((label, index) => (
{index < active ? : index + 1} {label} {index < 3 ? : null}
))}
) } const ANALYSIS_STAGES = [ { label: '解析材料', stages: ['queued', 'parsing', 'starting'] }, { label: '提取事实', stages: ['extracting', 'executing', 'generating', 'validating', 'repairing', 'retrying', 'extraction_completed'] }, { label: '创建工单', stages: ['creating_ticket'] }, { label: '完成', stages: ['completed'] } ] const EVENT_LABELS: Record = { queued: '任务已排队', parsing: '正在解析材料', extracting: '正在提取问题事实', starting: 'Agent 开始运行', executing: '正在执行受理步骤', generating: '正在生成结构化字段', validating: '正在校验提取结果', repairing: '正在修正结构化结果', retrying: '模型调用正在重试', extraction_completed: '字段提取已完成', creating_ticket: '正在创建正式工单', completed: '正式工单已创建', needs_input: '需要补充问题信息', cancellation_requested: '已请求取消受理', cancelled: '智能受理已取消', failed: '受理运行失败' } function AnalysisProgress({ draft, events, streamState, busy, onCancel }: { draft: TicketDraft events: AgentRunEvent[] streamState: 'connecting' | 'connected' | 'reconnecting' busy: boolean onCancel: () => void }) { const progress = draft.agent_progress const latestEvent = events[events.length - 1] const liveStage = latestEvent?.event_type ?? progress.stage const liveMessage = latestEvent?.payload.message ?? latestEvent?.payload.progress_message ?? progress.message const activeIndex = Math.max(0, ANALYSIS_STAGES.findIndex((item) => item.stages.includes(liveStage))) const elapsedSeconds = Math.max(Math.round(progress.elapsed_ms / 1000), 0) return (
智能受理运行 运行事件按服务端序号实时补充,断线后从最后一条继续。
{streamState === 'connected' ? '实时同步' : streamState === 'reconnecting' ? '正在重连' : '正在连接'}

{liveMessage}

{progress.retry_count ? 已重试 {progress.retry_count} 次 : null}

已等待 {elapsedSeconds} 秒。形成有效问题描述后,系统会直接创建正式工单。

{ANALYSIS_STAGES.map((item, index) => (
{index < activeIndex ? : null}{item.label}
))}
{events.length ? (
{events.slice(-4).map((event) => (
#{String(event.sequence).padStart(3, '0')} {EVENT_LABELS[event.event_type] ?? event.payload.message ?? event.event_type}
))}
) : null} {progress.slow ? 模型响应较慢,任务仍在继续无需重复提交。系统会继续等待并显示重试状态;你也可以稍后返回恢复这次受理。 : null}
) } export function TicketCreatePage() { const mobile = useIsMobile() const [availability, setAvailability] = React.useState(null) const [loading, setLoading] = React.useState(true) const [mode, setMode] = React.useState<'ai' | 'manual'>('manual') const [sourceText, setSourceText] = React.useState('') const [pendingFiles, setPendingFiles] = React.useState([]) const [draft, setDraft] = React.useState(null) const [recentDraft, setRecentDraft] = React.useState(null) const [form, setForm] = React.useState(initialForm) const [changedFields, setChangedFields] = React.useState>(new Set()) const [dirty, setDirty] = React.useState(false) const [guided, setGuided] = React.useState(false) const [answer, setAnswer] = React.useState('') const [conversation, setConversation] = React.useState([]) const [error, setError] = React.useState('') const [busy, setBusy] = React.useState(false) const [runEvents, setRunEvents] = React.useState([]) const [streamState, setStreamState] = React.useState<'connecting' | 'connected' | 'reconnecting'>('connecting') const lastEventSequence = React.useRef(0) const acceptDraft = React.useCallback((next: TicketDraft) => { setDraft(next) setConversation(next.messages.map((item) => ({ role: item.role, content: item.content }))) if (next.status === 'needs_input' || next.status === 'ready') { setForm((current) => { const merged = mergeForm(next.fields) const changed = new Set(Object.keys(merged).filter((key) => merged[key as keyof TicketInput] !== current[key as keyof TicketInput])) setChangedFields(changed) return merged }) setDirty(false) } }, []) React.useEffect(() => { let cancelled = false const requestedDraftId = new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('draft') Promise.all([ getTicketDraftAvailability(), listTicketDrafts(), requestedDraftId ? getTicketDraft(requestedDraftId).catch(() => null) : Promise.resolve(null) ]) .then(([nextAvailability, drafts, requestedDraft]) => { if (cancelled) return setAvailability(nextAvailability) setMode(nextAvailability.available ? 'ai' : 'manual') if (requestedDraft) { acceptDraft(requestedDraft) setSourceText(requestedDraft.source_text) setRecentDraft(null) } else { setRecentDraft(drafts[0] ?? null) } }) .catch(() => { if (!cancelled) setMode('manual') }) .finally(() => !cancelled && setLoading(false)) return () => { cancelled = true } }, [acceptDraft]) React.useEffect(() => { lastEventSequence.current = 0 setRunEvents([]) setStreamState('connecting') }, [draft?.agent_run_public_id]) React.useEffect(() => { if (!draft || draft.status !== 'analyzing' || !draft.agent_run_public_id) return let active = true let fallbackTimer: number | undefined const refreshDraft = async () => { try { const next = await getTicketDraft(draft.public_id) if (active) acceptDraft(next) } catch (reason) { if (active) setError(reason instanceof Error ? reason.message : '查询 AI 受理状态失败') } } // sequence 同时用于去重和组件重建后的续传;SSE 异常时再以查询接口兜底收敛终态。 const close = subscribeAgentRunEvents(draft.agent_run_public_id, { after: lastEventSequence.current, onOpen: () => { if (!active) return setStreamState('connected') if (fallbackTimer) window.clearTimeout(fallbackTimer) }, onEvent: (event) => { if (!active || event.sequence <= lastEventSequence.current) return lastEventSequence.current = event.sequence setRunEvents((current) => [...current, event].slice(-20)) if (['completed', 'failed', 'needs_input', 'cancelled', 'timed_out'].includes(event.event_type)) { void refreshDraft() } }, onError: () => { if (!active) return setStreamState('reconnecting') if (fallbackTimer) window.clearTimeout(fallbackTimer) fallbackTimer = window.setTimeout(() => void refreshDraft(), 2500) } }) return () => { active = false close() if (fallbackTimer) window.clearTimeout(fallbackTimer) } }, [acceptDraft, draft?.agent_run_public_id, draft?.public_id, draft?.status]) React.useEffect(() => { const ticket = draft?.created_ticket ?? draft?.confirmed_ticket if ((draft?.status === 'ticket_created' || draft?.status === 'confirmed') && ticket) { toast.success(draft.decision === 'auto_created' ? '已自动创建正式工单' : '已创建正式工单') window.location.hash = `/tickets/${ticket.id}` } }, [draft]) function updateField(field: Key, value: TicketInput[Key]) { setForm((current) => ({ ...current, [field]: value })) setDirty(Boolean(draft)) setChangedFields((current) => new Set(current).add(field)) } function validateForm() { const required: (keyof TicketInput)[] = ['title', 'description', 'category_code', 'priority', 'problem_performance', 'problem_object', 'submitter_name', 'submitter_org', 'environment'] const missing = required.filter((key) => !String(form[key] ?? '').trim()) if (missing.length) { setError(`请补齐:${missing.map((key) => FIELD_LABELS[key]).join('、')}`) return false } return true } async function startAi() { if (!sourceText.trim() && pendingFiles.length === 0) { setError('请先输入问题描述或上传问题材料'); return } setBusy(true); setError('') setRunEvents([]); lastEventSequence.current = 0 try { let next = await createTicketDraft(sourceText) // 每次上传都会推进 revision,必须串行使用服务端返回的新版本。 for (const file of pendingFiles) next = await uploadTicketDraftMaterial(next.public_id, file) next = await analyzeTicketDraft(next.public_id, next.revision) acceptDraft(next) setRecentDraft(null) } catch (reason) { setError(reason instanceof Error ? reason.message : 'AI 分析启动失败') } finally { setBusy(false) } } async function saveIfDirty(current: TicketDraft) { if (!dirty) return current // revision 是受理记录的乐观锁;冲突交给服务端拒绝,避免覆盖另一页面的补充结果。 const saved = await updateTicketDraft(current.public_id, current.revision, form) acceptDraft(saved) return saved } async function retryAnalysis() { if (!draft) return setBusy(true); setError('') try { const saved = await saveIfDirty(draft) acceptDraft(await analyzeTicketDraft(saved.public_id, saved.revision)) } catch (reason) { setError(reason instanceof Error ? reason.message : '重新分析失败') } finally { setBusy(false) } } async function cancelAnalysis() { if (!draft || draft.status !== 'analyzing') return setBusy(true); setError('') try { acceptDraft(await cancelTicketDraftAnalysis(draft.public_id, draft.revision)) toast.info('智能受理已取消,材料与已填字段仍可恢复') } catch (reason) { setError(reason instanceof Error ? reason.message : '取消智能受理失败') } finally { setBusy(false) } } async function submitAnswer() { if (!draft || !answer.trim()) return const question = draft.questions[0] ?? '请补充缺失信息' setBusy(true); setError('') try { const saved = await saveIfDirty(draft) acceptDraft(await answerTicketDraft(saved.public_id, saved.revision, question, answer.trim())) setAnswer('') } catch (reason) { setError(reason instanceof Error ? reason.message : '提交回答失败') } finally { setBusy(false) } } async function confirmDraft() { if (!draft || !validateForm()) return setBusy(true); setError('') try { const saved = await saveIfDirty(draft) const confirmed = await confirmTicketDraft(saved.public_id, saved.revision) acceptDraft(confirmed) toast.success('已按人工补全内容创建工单') const ticket = confirmed.created_ticket ?? confirmed.confirmed_ticket if (ticket) window.location.hash = `/tickets/${ticket.id}` } catch (reason) { setError(reason instanceof Error ? reason.message : '确认创建失败') } finally { setBusy(false) } } async function handleManualSubmit(event: React.FormEvent) { event.preventDefault() if (!validateForm()) return setBusy(true); setError('') try { const ticket = await createTicket(form); window.location.hash = `/tickets/${ticket.id}` } catch (reason) { setError(reason instanceof Error ? reason.message : '创建工单失败') } finally { setBusy(false) } } async function discardDraft() { if (!draft) return try { await rejectTicketDraft(draft.public_id, draft.revision) } catch { /* dismissal remains local */ } setDraft(null); setForm(initialForm); setSourceText(''); setPendingFiles([]); setConversation([]); setMode('manual') } function selectMaterials(files: File[]) { if (!availability) return const maxFiles = availability.max_files || 10 const maxFileSize = availability.max_file_size || 20 * 1024 * 1024 const maxTotalSize = availability.max_total_size || 100 * 1024 * 1024 if (files.length > maxFiles) { setError(`每次受理最多选择 ${maxFiles} 个材料`) return } const oversized = files.find((file) => file.size > maxFileSize) if (oversized) { setError(`${oversized.name} 超过单文件 ${formatFileSize(maxFileSize)} 限制`) return } if (files.reduce((total, file) => total + file.size, 0) > maxTotalSize) { setError(`材料总量不能超过 ${formatFileSize(maxTotalSize)}`) return } if (!availability.vision_available) { const image = files.find((file) => file.type.startsWith('image/')) if (image) { setError(`${image.name} 是图片,但当前受理模型不支持图片理解`) return } } setError('') setPendingFiles(files) } if (mobile) { return }>请使用桌面端创建工单移动端当前仅支持查看。输入、材料上传、逐问补齐和最终确认需要在桌面端完成。 } if (loading) return }>
const aiReady = mode === 'ai' && availability?.available const reviewReady = draft?.status === 'needs_input' || draft?.status === 'ready' const selectableExtensions = (availability?.supported_extensions ?? []) .filter((extension) => availability?.vision_available || !['jpg', 'jpeg', 'png', 'webp'].includes(extension)) const materialAccept = selectableExtensions.map((extension) => `.${extension}`).join(',') const materialLimitText = availability ? `最多 ${availability.max_files || 10} 个;单个 ${formatFileSize(availability.max_file_size || 20 * 1024 * 1024)},总计 ${formatFileSize(availability.max_total_size || 100 * 1024 * 1024)}。` : '' return ( } />} > {aiReady ? : null} {error ? 当前操作未完成{error} : null} {!availability?.available && availability?.reason ? AI 起草暂不可用{availability.reason},仍可使用普通表单建单。 : null} {recentDraft && !draft && aiReady ? 发现 24 小时内未完成受理{recentDraft.source_text || `仅包含 ${recentDraft.materials.length} 份材料的受理`} : null} {aiReady && !draft ? (