Files
ai-ticket-platform/frontend/src/features/tickets/TicketCreatePage.tsx
2026-07-16 19:37:37 +08:00

325 lines
24 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,
ArrowRight,
Bot,
Check,
CheckCircle2,
FileImage,
LoaderCircle,
MessageSquareMore,
RotateCcw,
Send,
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 } 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,
confirmTicketDraft,
createTicketDraft,
deleteTicketDraftImage,
getTicketDraft,
getTicketDraftAvailability,
listTicketDrafts,
rejectTicketDraft,
updateTicketDraft,
uploadTicketDraftImage
} from './draft-api'
import type { TicketDraft, TicketDraftAvailability } 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 DraftSpine({ draft }: { draft: TicketDraft | null }) {
const active = draft?.status === 'analyzing' ? 1 : draft?.status === 'ready' || draft?.status === 'failed' ? 2 : 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>
)
}
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 acceptDraft = React.useCallback((next: TicketDraft) => {
setDraft(next)
setConversation(next.messages.map((item) => ({ role: item.role, content: item.content })))
if (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
Promise.all([getTicketDraftAvailability(), listTicketDrafts()])
.then(([nextAvailability, drafts]) => {
if (cancelled) return
setAvailability(nextAvailability)
setMode(nextAvailability.available ? 'ai' : 'manual')
setRecentDraft(drafts[0] ?? null)
})
.catch(() => {
if (!cancelled) setMode('manual')
})
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [])
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])
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()) { setError('请先输入问题描述'); return }
setBusy(true); setError('')
try {
let next = await createTicketDraft(sourceText)
for (const file of pendingFiles) next = await uploadTicketDraftImage(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
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 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(confirmed.decision === 'accepted' ? '已按 AI 草稿创建工单' : '已按修改后的草稿创建工单')
if (confirmed.confirmed_ticket) window.location.hash = `/tickets/${confirmed.confirmed_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(''); setConversation([]); setMode('manual')
}
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 === 'ready'
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>} />}
>
{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}
{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>
<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>
{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}
</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>
</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>
</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 === '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}
{(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}
<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.images.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>
</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?.images.length ? <AppSection variant="card" title={`图片 ${draft.images.length}/4`}>{draft.images.map((image) => <div key={image.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, image.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>
</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></>
}