- {['起草', '补齐', '审核', '确认'].map((label, index) => (
+ {['提交材料', '智能解析', '补全事实', '正式建单'].map((label, index) => (
{index < active ? : index + 1}
@@ -121,6 +158,105 @@ function DraftSpine({ draft }: { draft: TicketDraft | 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)
@@ -138,11 +274,14 @@ export function TicketCreatePage() {
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 === 'ready') {
+ 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]))
@@ -155,27 +294,86 @@ export function TicketCreatePage() {
React.useEffect(() => {
let cancelled = false
- Promise.all([getTicketDraftAvailability(), listTicketDrafts()])
- .then(([nextAvailability, drafts]) => {
+ 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')
- setRecentDraft(drafts[0] ?? null)
+ 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(() => {
- if (!draft || draft.status !== 'analyzing') return
- const timer = window.setTimeout(async () => {
- try { acceptDraft(await getTicketDraft(draft.public_id)) } catch (reason) { setError(reason instanceof Error ? reason.message : '查询 AI 草稿失败') }
- }, 1200)
- return () => window.clearTimeout(timer)
- }, [acceptDraft, draft])
+ 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 }))
@@ -194,11 +392,13 @@ export function TicketCreatePage() {
}
async function startAi() {
- if (!sourceText.trim()) { setError('请先输入问题描述'); return }
+ if (!sourceText.trim() && pendingFiles.length === 0) { setError('请先输入问题描述或上传问题材料'); return }
setBusy(true); setError('')
+ setRunEvents([]); lastEventSequence.current = 0
try {
let next = await createTicketDraft(sourceText)
- for (const file of pendingFiles) next = await uploadTicketDraftImage(next.public_id, file)
+ // 每次上传都会推进 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)
@@ -207,6 +407,7 @@ export function TicketCreatePage() {
async function saveIfDirty(current: TicketDraft) {
if (!dirty) return current
+ // revision 是受理记录的乐观锁;冲突交给服务端拒绝,避免覆盖另一页面的补充结果。
const saved = await updateTicketDraft(current.public_id, current.revision, form)
acceptDraft(saved)
return saved
@@ -221,6 +422,19 @@ export function TicketCreatePage() {
} 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] ?? '请补充缺失信息'
@@ -239,8 +453,9 @@ export function TicketCreatePage() {
const saved = await saveIfDirty(draft)
const confirmed = await confirmTicketDraft(saved.public_id, saved.revision)
acceptDraft(confirmed)
- toast.success(confirmed.decision === 'accepted' ? '已按 AI 草稿创建工单' : '已按修改后的草稿创建工单')
- if (confirmed.confirmed_ticket) window.location.hash = `/tickets/${confirmed.confirmed_ticket.id}`
+ 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) }
}
@@ -254,58 +469,96 @@ export function TicketCreatePage() {
async function discardDraft() {
if (!draft) return
try { await rejectTicketDraft(draft.public_id, draft.revision) } catch { /* dismissal remains local */ }
- setDraft(null); setForm(initialForm); setSourceText(''); setConversation([]); setMode('manual')
+ 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 }>请使用桌面端创建工单移动端当前仅支持查看。输入、图片上传、逐问补齐和最终确认需要在桌面端完成。
+ return }>请使用桌面端创建工单移动端当前仅支持查看。输入、材料上传、逐问补齐和最终确认需要在桌面端完成。
}
if (loading) return }>
const aiReady = mode === 'ai' && availability?.available
- const reviewReady = draft?.status === 'ready'
+ 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 (
} />}
+ header={
} />}
>
{aiReady ?