feat: add trusted ticket intake workspace
This commit is contained in:
@@ -1,19 +1,71 @@
|
||||
import * as React from 'react'
|
||||
import { AlertCircle, CheckCircle2, Lightbulb, Send } from 'lucide-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 { toast } from 'sonner'
|
||||
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'
|
||||
|
||||
@@ -32,160 +84,241 @@ const initialForm: TicketInput = {
|
||||
}
|
||||
|
||||
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 [submitting, setSubmitting] = React.useState(false)
|
||||
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))
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
if (!form.environment || !form.problem_performance || !form.problem_object || !form.submitter_name || !form.submitter_org) {
|
||||
setError('请完整填写问题表现、问题对象、提出人、提出机构和环境。')
|
||||
return
|
||||
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
|
||||
}
|
||||
setSubmitting(true)
|
||||
return true
|
||||
}
|
||||
|
||||
async function startAi() {
|
||||
if (!sourceText.trim()) { setError('请先输入问题描述'); 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 {
|
||||
setSubmitting(false)
|
||||
}
|
||||
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-5"
|
||||
header={<AppPageHeader title="创建工单" description="按提出方、问题描述和影响范围记录完整上下文" actions={<><Button type="button" variant="ghost" onClick={() => (window.location.hash = '/tickets')}>取消</Button><Button type="button" variant="outline" onClick={() => toast('暂存入口为视觉预留,当前不会保存数据')}>暂存</Button></>} />}
|
||||
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>} />}
|
||||
>
|
||||
<form id="ticket-create-form" className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]" onSubmit={handleSubmit}>
|
||||
{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">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>创建工单失败</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<AppSection variant="card" title="提出方与对象" description="先确认由谁提出,以及具体影响到哪个业务对象。">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="提出人" htmlFor="ticket-submitter-name" required>
|
||||
<Input
|
||||
id="ticket-submitter-name"
|
||||
required
|
||||
placeholder="请输入提出人"
|
||||
value={form.submitter_name}
|
||||
onChange={(event) => updateField('submitter_name', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="提出机构" htmlFor="ticket-submitter-org" required>
|
||||
<Input
|
||||
id="ticket-submitter-org"
|
||||
required
|
||||
placeholder="请输入提出机构"
|
||||
value={form.submitter_org}
|
||||
onChange={(event) => updateField('submitter_org', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="问题对象" htmlFor="ticket-problem-object" required>
|
||||
<Input
|
||||
id="ticket-problem-object"
|
||||
required
|
||||
placeholder="客户、账号、机构或任务编号"
|
||||
value={form.problem_object}
|
||||
onChange={(event) => updateField('problem_object', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="环境" required>
|
||||
<Select value={form.environment} onValueChange={(value) => updateField('environment', value)}><SelectTrigger className="w-full"><SelectValue placeholder="请选择问题环境" /></SelectTrigger><SelectContent><SelectGroup>{ENVIRONMENT_OPTIONS.map((environment) => <SelectItem key={environment} value={environment}>{environment}</SelectItem>)}</SelectGroup></SelectContent></Select>
|
||||
</AppField>
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection variant="card" title="问题描述" description="描述用户看到的现象、发生时间与期望结果。">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="工单标题" htmlFor="ticket-title" required><Input id="ticket-title" required placeholder="例如:查询客户采集进度" value={form.title} onChange={(event) => updateField('title', event.target.value)} /></AppField>
|
||||
<AppField label="问题表现" htmlFor="ticket-problem-performance" required><Input id="ticket-problem-performance" required placeholder="例如:采集进度长时间未更新" value={form.problem_performance} onChange={(event) => updateField('problem_performance', event.target.value)} /></AppField>
|
||||
</div>
|
||||
<AppField label="问题描述" htmlFor="ticket-description" required><Textarea id="ticket-description" required placeholder="请填写问题背景、期望结果、已知线索或需要支持团队确认的内容。" value={form.description} onChange={(event) => updateField('description', event.target.value)} /></AppField>
|
||||
</AppSection>
|
||||
|
||||
<AppSection variant="card" title="分类与影响" description="用于确定处理队列、响应顺序和影响范围。">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="问题分类" required><Select value={form.category_code} onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}><SelectTrigger className="w-full"><SelectValue placeholder="请选择问题分类" /></SelectTrigger><SelectContent><SelectGroup>{TICKET_CATEGORIES.map((category) => <SelectItem key={category.code} value={category.code}>{category.label}</SelectItem>)}</SelectGroup></SelectContent></Select></AppField>
|
||||
<AppField label="优先级" required><Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}><SelectTrigger className="w-full"><SelectValue placeholder="请选择优先级" /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="low">低</SelectItem><SelectItem value="medium">中</SelectItem><SelectItem value="high">高</SelectItem><SelectItem value="urgent">紧急</SelectItem></SelectGroup></SelectContent></Select></AppField>
|
||||
<AppField label="影响范围" htmlFor="ticket-impact-scope">
|
||||
<Input
|
||||
id="ticket-impact-scope"
|
||||
placeholder="例如:单个客户、某部门、全部用户"
|
||||
value={form.impact_scope}
|
||||
onChange={(event) => updateField('impact_scope', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="关联系统" htmlFor="ticket-related-system">
|
||||
<Input
|
||||
id="ticket-related-system"
|
||||
placeholder="例如:CRM、财务系统、数据中台"
|
||||
value={form.related_system}
|
||||
onChange={(event) => updateField('related_system', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection
|
||||
variant="card"
|
||||
title="提交确认"
|
||||
description="提交后工单将进入对应的支持处理队列。"
|
||||
footer={
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">确认无误后提交</span>
|
||||
<Button className="hidden md:inline-flex" type="submit" disabled={submitting}>
|
||||
<Send data-icon="inline-start" />
|
||||
{submitting ? '提交中...' : '提交工单'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="mt-0.5 size-4" />
|
||||
<span>请确认分类、对象和描述已足够支持处理人员判断下一步动作。</span>
|
||||
</div>
|
||||
</AppSection>
|
||||
{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">
|
||||
<AppSection variant="card" title="建单提示">
|
||||
<div className="grid gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex gap-2">
|
||||
<Lightbulb className="mt-0.5 size-4" />
|
||||
<span>业务、操作类问题可在详情页直接填写解决方案并完成。</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Lightbulb className="mt-0.5 size-4" />
|
||||
<span>技术类问题会保留开始处理、提交确认、解决和关闭流程。</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Lightbulb className="mt-0.5 size-4" />
|
||||
<span>数据类问题支持处理后解决,也支持明确后直接解决。</span>
|
||||
</div>
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection variant="card" title="Agent 辅助" action={<Badge variant="ai">演示</Badge>}><p className="text-sm text-muted-foreground">右侧助手会根据当前页面字段提供排查结构,但不会提交或保存工单。</p></AppSection>
|
||||
{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>
|
||||
<div className="sticky bottom-3 rounded-lg border bg-card/95 p-3 shadow-lg backdrop-blur md:hidden"><Button form="ticket-create-form" type="submit" className="w-full" disabled={submitting}><Send data-icon="inline-start" />{submitting ? '提交中...' : '提交工单'}</Button></div>
|
||||
) : 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></>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user