578 lines
36 KiB
TypeScript
578 lines
36 KiB
TypeScript
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></>
|
||
}
|