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 = { 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 { 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 (
{['起草', '补齐', '审核', '确认'].map((label, index) => (
{index < active ? : index + 1} {label} {index < 3 ? : null}
))}
) } export function TicketCreatePage() { const mobile = useIsMobile() const [availability, setAvailability] = React.useState(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([]) const [draft, setDraft] = React.useState(null) const [recentDraft, setRecentDraft] = React.useState(null) const [form, setForm] = React.useState(initialForm) const [changedFields, setChangedFields] = React.useState>(new Set()) const [dirty, setDirty] = React.useState(false) const [guided, setGuided] = React.useState(false) const [answer, setAnswer] = React.useState('') const [conversation, setConversation] = React.useState([]) 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(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 }>请使用桌面端创建工单移动端当前仅支持查看。输入、图片上传、逐问补齐和最终确认需要在桌面端完成。 } if (loading) return }>
const aiReady = mode === 'ai' && availability?.available const reviewReady = draft?.status === 'ready' return ( } />} > {aiReady ? : null} {error ? 当前操作未完成{error} : null} {!availability?.available && availability?.reason ? AI 起草暂不可用{availability.reason},仍可使用普通表单建单。 : null} {recentDraft && !draft && aiReady ? 发现 24 小时内未完成草稿{recentDraft.source_text || '未命名草稿'} : null} {aiReady && !draft ? (