@@ -1,15 +1,22 @@
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'
@@ -34,7 +41,7 @@ import {
import { Badge } from '@/components/ui/badge'
import { Bubble , BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { Card , CardContent } from '@/components/ui/card'
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'
@@ -55,17 +62,19 @@ import { createTicket } from './api'
import {
analyzeTicketDraft ,
answerTicketDraft ,
cancelTicketDraftAnalysis ,
confirmTicketDraft ,
createTicketDraft ,
deleteTicketDraftImage ,
deleteTicketDraftMaterial ,
getTicketDraft ,
getTicketDraftAvailability ,
listTicketDrafts ,
rejectTicketDraft ,
subscribeAgentRunEvents ,
updateTicketDraft ,
uploadTicketDraftImage
uploadTicketDraftMaterial
} from './draft-api'
import type { TicketDraft , TicketDraftAvailability } from './draft-types'
import type { AgentRunEvent , TicketDraft, TicketDraftAvailability , TicketDraftMaterial } from './draft-types'
import { TICKET_CATEGORIES } from './ticketCategories'
import type { TicketInput } from './types'
@@ -104,11 +113,39 @@ 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 === 'ready' || draft ? . status === 'failed' ? 2 : draft?.status === 'confirmed' ? 3 : 0
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 ) = > (
{ [ '提交材料 ' , '智能解析 ' , '补全事实 ' , '正式建单 ' ] . 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 }
@@ -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 < 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 )
@@ -138,11 +274,14 @@ export function TicketCreatePage() {
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 === '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 ( [ getTicketDraftAvailabi lity ( ) , listTicketDrafts ( ) ] )
. then ( ( [ nextAvailability , drafts ] ) = > {
const requestedDraftId = new URLSearchParams ( window . location . hash . sp lit( '?' ) [ 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 ( d rafts [ 0 ] ? ? null )
if ( requestedD raft) {
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 < Key extends keyof TicketInput > ( 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 . confirm ed_ticket ) window . location . hash = ` /tickets/ ${ confirmed . confirmed_ticket . id } `
toast . success ( '已按人工补全内容 创建工单')
const ticket = confirmed . creat ed_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 < AppPage header = { < AppPageHeader title = "创建工单" description = "AI 建单配置与确认仅支持桌面端" / > } > < Alert > < AlertCircle / > < AlertTitle > 请 使 用 桌 面 端 创 建 工 单 < / AlertTitle > < AlertDescription > 移 动 端 当 前 仅 支 持 查 看 。 输 入 、 图 片 上 传 、 逐 问 补 齐 和 最 终 确 认 需 要 在 桌 面 端 完 成 。 < / AlertDescription > < / Alert > < / AppPage >
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 === '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 (
< 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" / > AI 起 草 < / Button > < Button variant = { mode === 'manual' ? 'default' : 'outline' } onClick = { ( ) = > setMode ( 'manual' ) } > 普 通 表 单 < / Button > < / div > } / > }
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 || '未命名草稿' } < / span > < Button size = "sm" variant = "outline" onClick = { ( ) = > { acceptDraft ( recentDraft ) ; setSourceText ( recentDraft . source_text ) } } > 恢 复 草 稿 < / Button > < / 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 = "可包含现象、对象、环境、发生时间和期望结果 。" >
< AppField label = "问题描述 " htmlFor = "ai-ticket-source" required > < Textarea id = "ai-ticket-source" className = "min-h-44" value = { sourceText } onChange = { ( event ) = > setSourceText ( event . target . value ) } placeholder = "例如:生产环境中,客户 A 的采集任务从上午 10 点起没有更新,任务编号为 JOB-1024, 请技术支持排查。" / > < / AppField >
< 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 = { availability ? . vision_available ? '最多 4 张;单张 10 MB, 总计 20 MB; 支持 JPEG、PNG、WebP。' : availability ? . vision_reason } >
< Input type = "file" accept = "image/jpeg,image/png,image/webp" multiple disabled = { ! availability ? . vision_available } onChange = { ( event ) = > setPendingFiles ( Array . from ( event . target . files ? ? [ ] ) . slice ( 0 , 4 ) ) } / >
< 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" > < FileImage className = "size-3" / > { file . name } < / Badge > ) } < / div > : null }
{ 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 ( ) } > { busy ? < LoaderCircle className = "animate-spin" / > : < Sparkles / > } < span > { busy ? '准备分析 …' : '生成结构化草稿 ' } < / span > < / Button > < / div >
< 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 = "AI 会做什么 " action = { < Badge variant = "ai" > 真 实 功 能 < / Badge > } > < div className = "grid gap-3 text-sm text-muted-foreground" > < p > 提 取 全 部 工 单 字 段 , 并 标 出 缺 失 信 息 。 < / p > < p > 默 认 集 中 展 示 缺 失 项 , 也 可 切 换 为 最 多 5 轮 逐 问 。 < / p > < p > 你 可 以 修 改 任 意 字 段 , 最 终 必 须 显 式 确 认 。 < / p > < / 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' ? < Card > < CardContent className = "flex min-h-56 flex-col items-center justify-center gap-3" > < LoaderCircle className = "size-8 animate-spin text-primary" / > < div className = "text-center" > < p className = "font-medium" > AI 正 在 生 成 可 审 核 草 稿 < / p > < p className = "text-sm text-muted-foreground" > 页 面 会 自 动 刷 新 , 当 前 不 会 创 建 工 单 。 < / p > < / div > < / CardContent > < / Card > : null }
{ draft ? . status === 'analyzing' ? < AnalysisProgress draft = { draft } events = { runEvents } streamState = { streamState } busy = { busy } onCancel = { ( ) = > void cancelAnalysis ( ) } / > : null }
{ draft ? . status === 'failed' ? < Alert variant = " destructive" > < AlertCircle / > < AlertTitle > 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 }
{ 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 > 还 需 补 齐 { draft . missing_fields . length } 项 < / AlertTitle > < AlertDescription > { draft . missing_fields . map ( ( code ) = > FIELD_LABELS [ code ] ? ? code ) . join ( '、' ) } < / AlertDescription > < / Alert > : null }
{ 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。' } 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 . i mage s. 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 ? 'AI 产物与人工修改会作为整单决定保留, AI 不会自动提交 。' : '提交后工单进入支持处理队列。' } < / span > < / div > < / AppSection >
< 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 . material s . 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 ? . i mage s. length ? < AppSection variant = "card" title = { ` 图片 ${ draft . i mage s. length } /4 ` } > { draft . i mage s. map ( ( i mage ) = > < div key = { i mage . id } className = "flex items-center justify-between gap-2 rounded-lg border p-2 text-sm" > < span className = "truncate" > < FileImage className = "mr-2 inline size-4" / > { image . file_name } < / span > { draft . status !== 'confirmed' ? < Button type = "button" variant = "ghost" size = "icon-sm" onClick = { async ( ) = > { try { acceptDraft ( await deleteTicketDraftImage ( draft . public_id , i mage . id ) ) } catch ( reason ) { toast . error ( reason instanceof Error ? reason . message : '删除失败' ) } } } > < Trash2 / > < span className = "sr-only" > 删 除 图 片 < / span > < / Button > : null } < / div > ) } < / AppSection > : null }
< AppSection variant = "card" title = "安全 边界" > < div className = "grid gap-2 text-sm text-muted-foreground" > < p > AI 只 生 成 草 稿 , 不 创 建 或 修 改 工 单 。 < / p > < p > 连 续 点 击 或 网 络 重 试 只 会 返 回 同 一 张 工 单 。 < / p > < p > 管 理 员 审 计 不 保 存 完 整 原 文 、 图 片 或 模 型 响 应 。 < / p > < / div > < / AppSection >
{ draft ? . material s . length ? < AppSection variant = "card" title = { ` 材料清单 ${ draft . material s . length } /${ availability ? . max_files || 10 } ` } > { draft . material s . 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 }
@@ -315,7 +568,7 @@ export function TicketCreatePage() {
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 >
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 } ) {