Files
ai-ticket-platform/frontend/src/features/tickets/TicketCreatePage.tsx
2026-07-27 18:59:28 +08:00

578 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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>): 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<TicketDraftMaterial, 'material_type'> }) {
if (material.material_type === 'image') return <FileImage className="size-4" />
if (material.material_type === 'spreadsheet') return <FileSpreadsheet className="size-4" />
if (material.material_type === 'presentation') return <Presentation className="size-4" />
if (material.material_type === 'document' || material.material_type === 'text' || material.material_type === 'log') return <FileText className="size-4" />
return <File className="size-4" />
}
function MaterialStatus({ material }: { material: TicketDraftMaterial }) {
if (material.scan_status === 'infected') return <Badge variant="destructive">已拦截</Badge>
if (material.scan_status === 'failed') return <Badge variant="destructive">扫描失败</Badge>
if (material.parse_status === 'failed' || material.parse_status === 'blocked') return <Badge variant="warning">解析失败</Badge>
if (material.scan_status === 'clean' && ['parsed', 'not_applicable'].includes(material.parse_status)) return <Badge variant="success"><ShieldCheck />可用</Badge>
return <Badge variant="secondary">处理中</Badge>
}
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 (
<div className="grid grid-cols-4 gap-2 rounded-xl border bg-card p-3" aria-label="建单进度">
{['提交材料', '智能解析', '补全事实', '正式建单'].map((label, index) => (
<div key={label} className="flex min-w-0 items-center gap-2">
<span className={cn('flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold', index < active && 'border-primary bg-primary text-primary-foreground', index === active && 'border-primary text-primary')}>
{index < active ? <Check className="size-3.5" /> : index + 1}
</span>
<span className={cn('truncate text-xs font-medium text-muted-foreground', index === active && 'text-foreground')}>{label}</span>
{index < 3 ? <Separator className="hidden flex-1 sm:block" /> : null}
</div>
))}
</div>
)
}
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<string, string> = {
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 (
<Card className="gap-0 py-0">
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b bg-muted/30 py-3.5">
<div className="grid gap-1">
<CardTitle className="text-sm font-semibold">智能受理运行</CardTitle>
<CardDescription className="text-xs leading-5">运行事件按服务端序号实时补充,断线后从最后一条继续。</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant={streamState === 'connected' ? 'success' : 'secondary'}>
<Activity />{streamState === 'connected' ? '实时同步' : streamState === 'reconnecting' ? '正在重连' : '正在连接'}
</Badge>
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={busy}>
<Ban data-icon="inline-start" />取消受理
</Button>
</div>
</CardHeader>
<CardContent className="grid min-h-56 content-center gap-5 p-6">
<div className="flex items-center justify-center gap-3">
<LoaderCircle className="size-7 animate-spin text-primary" />
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{liveMessage}</p>
{progress.retry_count ? <Badge variant="warning">已重试 {progress.retry_count} 次</Badge> : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">已等待 {elapsedSeconds} 秒。形成有效问题描述后,系统会直接创建正式工单。</p>
</div>
</div>
<div className="grid grid-cols-4 gap-2" aria-label="AI 分析阶段">
{ANALYSIS_STAGES.map((item, index) => (
<div key={item.label} className={cn('rounded-md border px-2 py-2 text-center text-xs text-muted-foreground', index < activeIndex && 'border-success/40 bg-success/10 text-success', index === activeIndex && 'border-primary/50 bg-primary/5 font-medium text-foreground')}>
{index < activeIndex ? <Check className="mr-1 inline size-3" /> : null}{item.label}
</div>
))}
</div>
{events.length ? (
<div className="grid gap-1.5 rounded-lg border bg-muted/20 p-3" aria-label="实时运行事件">
{events.slice(-4).map((event) => (
<div key={event.sequence} className="grid grid-cols-[3.5rem_minmax(0,1fr)] items-center gap-2 text-xs">
<span className="font-mono text-muted-foreground">#{String(event.sequence).padStart(3, '0')}</span>
<span className={cn('truncate text-muted-foreground', event === latestEvent && 'font-medium text-foreground')}>
{EVENT_LABELS[event.event_type] ?? event.payload.message ?? event.event_type}
</span>
</div>
))}
</div>
) : null}
{progress.slow ? <Alert><RotateCcw /><AlertTitle>模型响应较慢,任务仍在继续</AlertTitle><AlertDescription>无需重复提交。系统会继续等待并显示重试状态;你也可以稍后返回恢复这次受理。</AlertDescription></Alert> : null}
</CardContent>
</Card>
)
}
export function TicketCreatePage() {
const mobile = useIsMobile()
const [availability, setAvailability] = React.useState<TicketDraftAvailability | null>(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<File[]>([])
const [draft, setDraft] = React.useState<TicketDraft | null>(null)
const [recentDraft, setRecentDraft] = React.useState<TicketDraft | null>(null)
const [form, setForm] = React.useState<TicketInput>(initialForm)
const [changedFields, setChangedFields] = React.useState<Set<string>>(new Set())
const [dirty, setDirty] = React.useState(false)
const [guided, setGuided] = React.useState(false)
const [answer, setAnswer] = React.useState('')
const [conversation, setConversation] = React.useState<ConversationItem[]>([])
const [error, setError] = React.useState('')
const [busy, setBusy] = React.useState(false)
const [runEvents, setRunEvents] = React.useState<AgentRunEvent[]>([])
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<Key extends keyof TicketInput>(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 <AppPage header={<AppPageHeader title="创建工单" description="AI 建单配置与确认仅支持桌面端" />}><Alert><AlertCircle /><AlertTitle>请使用桌面端创建工单</AlertTitle><AlertDescription>移动端当前仅支持查看。输入、材料上传、逐问补齐和最终确认需要在桌面端完成。</AlertDescription></Alert></AppPage>
}
if (loading) return <AppPage header={<AppPageHeader title="创建工单" />}><div className="grid gap-3"><Skeleton className="h-20" /><Skeleton className="h-64" /></div></AppPage>
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 (
<AppPage
contentClassName="grid gap-4"
header={<AppPageHeader title="创建工单" description="提交聊天材料后,AI 会提取事实;问题描述有效时直接创建正式工单。" actions={<div className="flex gap-2"><Button variant={mode === 'ai' ? 'default' : 'outline'} onClick={() => availability?.available && setMode('ai')} disabled={!availability?.available}><Sparkles data-icon="inline-start" />智能受理</Button><Button variant={mode === 'manual' ? 'default' : 'outline'} onClick={() => setMode('manual')}>普通表单</Button></div>} />}
>
{aiReady ? <DraftSpine draft={draft} /> : null}
{error ? <Alert variant="destructive"><AlertCircle /><AlertTitle>当前操作未完成</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
{!availability?.available && availability?.reason ? <Alert><Bot /><AlertTitle>AI 起草暂不可用</AlertTitle><AlertDescription>{availability.reason},仍可使用普通表单建单。</AlertDescription></Alert> : null}
{recentDraft && !draft && aiReady ? <Alert><RotateCcw /><AlertTitle>发现 24 小时内未完成受理</AlertTitle><AlertDescription className="flex items-center justify-between gap-3"><span>{recentDraft.source_text || `仅包含 ${recentDraft.materials.length} 份材料的受理`}</span><Button size="sm" variant="outline" onClick={() => { acceptDraft(recentDraft); setSourceText(recentDraft.source_text) }}>恢复受理</Button></AlertDescription></Alert> : null}
{aiReady && !draft ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<AppSection variant="card" title="提交问题材料" description="粘贴聊天记录,也可以上传截图、PDF、文本、日志或 Office 文档。">
<AppField label="聊天记录或补充说明" htmlFor="ai-ticket-source"><Textarea id="ai-ticket-source" className="min-h-44" value={sourceText} onChange={(event) => setSourceText(event.target.value)} placeholder="例如:生产环境中,客户 A 的采集任务从上午 10 点起没有更新,任务编号为 JOB-1024,请技术支持排查。" /></AppField>
<FieldGroup className="gap-3">
<AppField
label="问题材料(可选)"
description={`${materialLimitText} 支持 ${selectableExtensions.map((item) => item.toUpperCase()).join('、')}。${availability?.vision_available ? '' : ` ${availability?.vision_reason || ''}`}`}
>
<Input type="file" accept={materialAccept} multiple onChange={(event) => selectMaterials(Array.from(event.target.files ?? []))} />
</AppField>
{pendingFiles.length ? <div className="flex flex-wrap gap-2">{pendingFiles.map((file) => <Badge key={`${file.name}-${file.size}`} variant="secondary"><FileText />{file.name} · {formatFileSize(file.size)}</Badge>)}</div> : null}
</FieldGroup>
<div className="flex justify-end"><Button onClick={() => void startAi()} disabled={busy || (!sourceText.trim() && pendingFiles.length === 0)}>{busy ? <LoaderCircle className="animate-spin" /> : <Sparkles data-icon="inline-start" />}<span>{busy ? '准备受理…' : '开始智能受理'}</span></Button></div>
</AppSection>
<AppSection variant="card" title="受理规则" action={<Badge variant="ai">自动建单</Badge>}><div className="grid gap-3 text-sm text-muted-foreground"><p>材料先校验格式并执行恶意文件扫描,通过后才进入解析。</p><p>单份材料失败不会删除原件,也不会伪造解析结果。</p><p>形成有效问题描述后立即建单,其他字段允许后续补齐。</p></div></AppSection>
</div>
) : null}
{draft?.status === 'analyzing' ? <AnalysisProgress draft={draft} events={runEvents} streamState={streamState} busy={busy} onCancel={() => void cancelAnalysis()} /> : null}
{draft?.status === 'failed' ? <Alert variant={draft.error_code === 'analysis_cancelled' ? 'default' : 'destructive'}><AlertCircle /><AlertTitle>{draft.error_code === 'analysis_cancelled' ? '智能受理已取消' : 'AI 分析失败'}</AlertTitle><AlertDescription className="grid gap-3"><span>{draft.error_message || '原文、材料和已填字段均已保留。'}</span><div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => void retryAnalysis()} disabled={busy}>重新分析</Button><Button variant="outline" size="sm" onClick={() => setMode('manual')}>转为普通表单</Button></div></AlertDescription></Alert> : null}
{(reviewReady || mode === 'manual' || draft?.status === 'failed') ? (
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleManualSubmit}>
<div className="grid gap-4">
{reviewReady && draft.missing_fields.length ? <Alert><MessageSquareMore /><AlertTitle>尚未形成可自动建单的完整描述</AlertTitle><AlertDescription>{draft.invalid_reason || `建议补充:${draft.missing_fields.map((code) => FIELD_LABELS[code] ?? code).join('、')}`}</AlertDescription></Alert> : null}
<TicketFields form={form} changedFields={changedFields} updateField={updateField} />
<AppSection variant="card" title={reviewReady ? '人工兜底建单' : '提交工单'} description={reviewReady ? '当 AI 无法形成有效问题时,可补齐字段后人工创建。' : '普通表单不会调用 AI。'} footer={<div className="ml-auto flex gap-2">{draft ? <Button type="button" variant="ghost" onClick={() => void discardDraft()}><Trash2 data-icon="inline-start" />放弃受理</Button> : null}{reviewReady ? <AlertDialog><AlertDialogTrigger asChild><Button type="button" disabled={busy}><CheckCircle2 data-icon="inline-start" />按补全内容建单</Button></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>按人工补全内容创建工单?</AlertDialogTitle><AlertDialogDescription>标题:{form.title || '未填写'};分类:{TICKET_CATEGORIES.find((item) => item.code === form.category_code)?.label ?? form.category_code};优先级:{form.priority};材料 {draft.materials.length} 份。确认后将幂等创建正式工单。</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>返回检查</AlertDialogCancel><AlertDialogAction onClick={() => void confirmDraft()}>创建工单</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog> : <Button type="submit" disabled={busy}><Send data-icon="inline-start" />{busy ? '创建中…' : '创建工单'}</Button>}</div>}><div className="flex items-start gap-2 text-sm text-muted-foreground"><CheckCircle2 className="mt-0.5 size-4" /><span>{reviewReady ? '人工补全值会记录为受保护字段,后续 Agent 不会静默覆盖。' : '提交后工单进入支持处理队列。'}</span></div></AppSection>
</div>
<div className="grid content-start gap-4">
{reviewReady ? <AppSection variant="card" title="缺失信息处理" action={<Button size="sm" variant="outline" onClick={() => setGuided((value) => !value)} disabled={draft.guided_rounds >= 5}>{guided ? '关闭逐问' : '切换逐问'}</Button>}>{guided ? <GuidedPanel draft={draft} conversation={conversation} answer={answer} setAnswer={setAnswer} submitAnswer={submitAnswer} busy={busy} /> : <div className="grid gap-2">{draft.questions.length ? draft.questions.map((question) => <div key={question} className="rounded-lg border bg-muted/40 p-3 text-sm">{question}</div>) : <p className="text-sm text-muted-foreground">AI 未发现缺失信息,请直接审核字段。</p>}</div>}</AppSection> : null}
{draft?.materials.length ? <AppSection variant="card" title={`材料清单 ${draft.materials.length}/${availability?.max_files || 10}`}>{draft.materials.map((material) => <div key={material.id} className="flex items-center justify-between gap-3 rounded-lg border p-3 text-sm"><div className="flex min-w-0 items-center gap-2"><MaterialIcon material={material} /><div className="min-w-0"><p className="truncate font-medium">{material.file_name}</p><p className="text-xs text-muted-foreground">{formatFileSize(material.file_size)} · {material.extension.toUpperCase()}{material.parse_error ? ` · ${material.parse_error}` : ''}</p></div></div><div className="flex shrink-0 items-center gap-1"><MaterialStatus material={material} />{draft.status !== 'ticket_created' && draft.status !== 'confirmed' ? <Button type="button" variant="ghost" size="icon-sm" onClick={async () => { try { acceptDraft(await deleteTicketDraftMaterial(draft.public_id, material.id)) } catch (reason) { toast.error(reason instanceof Error ? reason.message : '删除失败') } }}><Trash2 /><span className="sr-only">删除材料</span></Button> : null}</div></div>)}</AppSection> : null}
<AppSection variant="card" title="受理边界"><div className="grid gap-2 text-sm text-muted-foreground"><p>只有形成有效问题描述后才会自动创建正式工单。</p><p>连续点击或任务重试只会关联同一张工单。</p><p>原始材料、字段来源和 Agent 运行均可追溯。</p></div></AppSection>
</div>
</form>
) : null}
</AppPage>
)
}
function GuidedPanel({ draft, conversation, answer, setAnswer, submitAnswer, busy }: { draft: TicketDraft; conversation: ConversationItem[]; answer: string; setAnswer: (value: string) => void; submitAnswer: () => void; busy: boolean }) {
const currentQuestion = draft.questions[0]
return <div className="grid gap-3"><div className="h-64 overflow-hidden rounded-lg border"><MessageScroller><MessageScrollerViewport><MessageScrollerContent className="p-3"><MessageGroup>{conversation.map((item, index) => <MessageScrollerItem key={`${index}-${item.role}`}><Message align={item.role === 'user' ? 'end' : 'start'}><MessageContent><Bubble variant={item.role === 'user' ? 'default' : 'muted'} align={item.role === 'user' ? 'end' : 'start'}><BubbleContent>{item.content}</BubbleContent></Bubble></MessageContent></Message></MessageScrollerItem>)}{currentQuestion ? <MessageScrollerItem scrollAnchor><Message><MessageContent><Bubble variant="muted"><BubbleContent>{currentQuestion}</BubbleContent></Bubble></MessageContent></Message></MessageScrollerItem> : null}</MessageGroup></MessageScrollerContent></MessageScrollerViewport></MessageScroller></div><div className="flex gap-2"><Input value={answer} onChange={(event) => setAnswer(event.target.value)} placeholder={draft.guided_rounds >= 5 ? '已达到 5 轮上限' : '输入补充信息'} disabled={busy || draft.guided_rounds >= 5 || !currentQuestion} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitAnswer() } }} /><Button type="button" size="icon" onClick={submitAnswer} disabled={busy || !answer.trim()}><ArrowRight /><span className="sr-only">提交回答</span></Button></div><p className="text-xs text-muted-foreground">已进行 {draft.guided_rounds}/5 轮;每次回答都会重新分析受理材料并高亮变化。</p></div>
}
function TicketFields({ form, changedFields, updateField }: { form: TicketInput; changedFields: Set<string>; updateField: <Key extends keyof TicketInput>(field: Key, value: TicketInput[Key]) => void }) {
const fieldClass = (code: keyof TicketInput) => changedFields.has(code) ? 'rounded-lg ring-2 ring-primary/30' : ''
return <><AppSection variant="card" title="提出方与对象" description="AI 生成字段已高亮,所有值都可修改。"><div className="grid gap-4 md:grid-cols-2"><AppField className={fieldClass('submitter_name')} label="提出人" required><Input value={form.submitter_name} onChange={(event) => updateField('submitter_name', event.target.value)} /></AppField><AppField className={fieldClass('submitter_org')} label="提出机构" required><Input value={form.submitter_org} onChange={(event) => updateField('submitter_org', event.target.value)} /></AppField><AppField className={fieldClass('problem_object')} label="问题对象" required><Input value={form.problem_object} onChange={(event) => updateField('problem_object', event.target.value)} /></AppField><AppField className={fieldClass('environment')} label="环境" required><Select value={form.environment} onValueChange={(value) => updateField('environment', value)}><SelectTrigger className="w-full"><SelectValue placeholder="请选择环境" /></SelectTrigger><SelectContent><SelectGroup>{ENVIRONMENT_OPTIONS.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectGroup></SelectContent></Select></AppField></div></AppSection><AppSection variant="card" title="问题描述"><div className="grid gap-4 md:grid-cols-2"><AppField className={fieldClass('title')} label="工单标题" required><Input value={form.title} onChange={(event) => updateField('title', event.target.value)} /></AppField><AppField className={fieldClass('problem_performance')} label="问题表现" required><Input value={form.problem_performance} onChange={(event) => updateField('problem_performance', event.target.value)} /></AppField></div><AppField className={fieldClass('description')} label="问题描述" required><Textarea value={form.description} onChange={(event) => updateField('description', event.target.value)} /></AppField></AppSection><AppSection variant="card" title="分类与影响"><div className="grid gap-4 md:grid-cols-2"><AppField className={fieldClass('category_code')} label="问题分类" required><Select value={form.category_code} onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{TICKET_CATEGORIES.map((item) => <SelectItem key={item.code} value={item.code}>{item.label}</SelectItem>)}</SelectContent></Select></AppField><AppField className={fieldClass('priority')} label="优先级" required><Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="low">低</SelectItem><SelectItem value="medium">中</SelectItem><SelectItem value="high">高</SelectItem><SelectItem value="urgent">紧急</SelectItem></SelectContent></Select></AppField><AppField className={fieldClass('impact_scope')} label="影响范围"><Input value={form.impact_scope} onChange={(event) => updateField('impact_scope', event.target.value)} /></AppField><AppField className={fieldClass('related_system')} label="关联系统"><Input value={form.related_system} onChange={(event) => updateField('related_system', event.target.value)} /></AppField></div></AppSection></>
}