feat: add trusted ticket intake workspace

This commit is contained in:
hailin
2026-07-16 19:37:37 +08:00
parent bcd6de4609
commit 5db4c625ed
8 changed files with 522 additions and 141 deletions

View File

@@ -1,6 +1,7 @@
import type * as React from 'react'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { cn } from '@/lib/utils'
type AppFieldProps = {
label: string
@@ -8,12 +9,13 @@ type AppFieldProps = {
description?: string
invalid?: boolean
required?: boolean
className?: string
children: React.ReactNode
}
export function AppField({ label, htmlFor, description, invalid, required, children }: AppFieldProps) {
export function AppField({ label, htmlFor, description, invalid, required, className, children }: AppFieldProps) {
return (
<Field data-invalid={invalid ? true : undefined}>
<Field className={cn(className)} data-invalid={invalid ? true : undefined}>
<FieldLabel htmlFor={htmlFor}>
{label}
{required ? <span className="text-destructive">*</span> : null}

View File

@@ -26,8 +26,8 @@ import { formatDateTime } from '@/lib/format'
import { createDeployment, createProvider, getModelTest, listDeployments, listProviders, testDeployment, updateDeployment, updateProvider } from './api'
import type { ModelDeployment, ModelInvocation, ModelProvider } from './types'
const providerInitial = { name: '', base_url: '', api_key: '', default_timeout_seconds: '60', enabled: true }
const deploymentInitial = { provider_id: '', display_name: '', model_name: '', default_temperature: '0.2', max_output_tokens: '2048' }
const providerInitial = { name: '', base_url: '', api_key: '', default_timeout_seconds: '60', enabled: true, image_egress_approved: false }
const deploymentInitial = { provider_id: '', display_name: '', model_name: '', capability: 'text_chat', default_temperature: '0.2', max_output_tokens: '2048' }
export function ModelProviderPage() {
const mobile = useIsMobile()
@@ -110,7 +110,7 @@ export function ModelProviderPage() {
if (!editing) return
try {
const keyInput = document.querySelector<HTMLInputElement>('#edit-provider-api-key')?.value ?? ''
await updateProvider(editing.id, { name: editing.name, base_url: editing.base_url, default_timeout_seconds: editing.default_timeout_seconds, enabled: editing.enabled, api_key: keyInput })
await updateProvider(editing.id, { name: editing.name, base_url: editing.base_url, default_timeout_seconds: editing.default_timeout_seconds, enabled: editing.enabled, image_egress_approved: editing.image_egress_approved, api_key: keyInput })
setEditing(null)
toast.success('渠道配置已保存')
await load()
@@ -140,6 +140,7 @@ export function ModelProviderPage() {
await updateDeployment(editingDeployment.id, {
display_name: editingDeployment.display_name,
model_name: editingDeployment.model_name,
capability: editingDeployment.capability,
default_temperature: editingDeployment.default_temperature,
max_output_tokens: editingDeployment.max_output_tokens,
enabled: editingDeployment.enabled
@@ -159,6 +160,7 @@ export function ModelProviderPage() {
{ key: 'name', header: '渠道', cell: (item) => <div><p className="font-medium">{item.name}</p><p className="text-xs text-muted-foreground">{item.base_url}</p></div> },
{ key: 'credential', header: '凭证', cell: (item) => <span className="inline-flex items-center gap-1 text-sm text-muted-foreground"><KeyRound className="size-3.5" />{item.credential_set ? item.credential_masked : '未配置'}</span> },
{ key: 'timeout', header: '超时', cell: (item) => `${item.default_timeout_seconds} 秒` },
{ key: 'image-egress', header: '图片出境', cell: (item) => <Badge title={item.image_egress_approved_at ? `${item.image_egress_approved_by ?? '管理员'} · ${formatDateTime(item.image_egress_approved_at)}` : undefined} variant={item.image_egress_approved ? 'warning' : 'secondary'}>{item.image_egress_approved ? '已授权' : '未授权'}</Badge> },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '已启用' : '已停用'}</Badge> },
{ key: 'updated', header: '更新时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span> },
{ key: 'action', header: '操作', cell: (item) => <Button variant="ghost" size="sm" onClick={() => setEditing({ ...item })}><Pencil data-icon="inline-start" />编辑</Button> }
@@ -166,6 +168,7 @@ export function ModelProviderPage() {
const deploymentColumns: AppDataTableColumn<ModelDeployment>[] = [
{ key: 'name', header: '模型', cell: (item) => <div><p className="font-medium">{item.display_name}</p><p className="text-xs text-muted-foreground">{item.model_name}</p></div> },
{ key: 'provider', header: '渠道', cell: (item) => item.provider_name },
{ key: 'capability', header: '能力', cell: (item) => <Badge variant="outline">{item.capability === 'vision_chat' ? '文本 + 视觉' : '纯文本'}</Badge> },
{ key: 'params', header: '默认参数', cell: (item) => <span className="text-muted-foreground">T {item.default_temperature} · {item.max_output_tokens} tokens</span> },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '可用' : '停用'}</Badge> },
{ key: 'action', header: '操作', cell: (item) => <div className="flex gap-1"><Button variant="ghost" size="sm" onClick={() => setEditingDeployment({ ...item })}><Settings2 data-icon="inline-start" />配置</Button><Button variant="outline" size="sm" onClick={() => { setTesting(item); setTestResult(null) }}><Activity data-icon="inline-start" />连通测试</Button></div> }
@@ -223,6 +226,7 @@ export function ModelProviderPage() {
<AppField label="请求超时(秒)" htmlFor="provider-timeout">
<Input id="provider-timeout" type="number" min="1" max="300" value={providerForm.default_timeout_seconds} onChange={(event) => setProviderForm({ ...providerForm, default_timeout_seconds: event.target.value })} />
</AppField>
<AppField label="图片数据出境授权" description="开启后,该渠道允许接收管理员确认的工单截图。授权会记录当前管理员与时间。"><div className="flex items-center gap-2"><Switch checked={providerForm.image_egress_approved} onCheckedChange={(checked) => setProviderForm({ ...providerForm, image_egress_approved: checked })} /><span className="text-sm text-muted-foreground">{providerForm.image_egress_approved ? '已确认授权' : '不允许发送图片'}</span></div></AppField>
</FieldGroup>
</AppCreateDialog>
@@ -250,6 +254,7 @@ export function ModelProviderPage() {
<AppField label="实际模型名" htmlFor="deployment-model" description="填写渠道可识别的模型标识。" required>
<Input id="deployment-model" required value={deploymentForm.model_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, model_name: event.target.value })} placeholder="例如:gpt-4.1-mini" />
</AppField>
<AppField label="模型能力" required><Select value={deploymentForm.capability} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem></SelectContent></Select></AppField>
<AppField label="Temperature" htmlFor="deployment-temperature" description="0 更稳定,数值越高结果越发散。">
<Input id="deployment-temperature" type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(event) => setDeploymentForm({ ...deploymentForm, default_temperature: event.target.value })} />
</AppField>
@@ -265,11 +270,13 @@ export function ModelProviderPage() {
<AppField label="替换 API Key" description="留空表示不替换"><Input id="edit-provider-api-key" type="password" placeholder={editing.credential_masked || '输入新密钥'} /></AppField>
<AppField label="默认超时"><Input type="number" min="1" max="300" value={editing.default_timeout_seconds} onChange={(e) => setEditing({ ...editing, default_timeout_seconds: Number(e.target.value) })} /></AppField>
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editing.enabled} onCheckedChange={(checked) => setEditing({ ...editing, enabled: checked })} /><span className="text-sm text-muted-foreground">{editing.enabled ? '已启用' : '已停用'}</span></div></AppField>
<AppField label="图片数据出境授权" description="仅视觉模型且渠道已授权时,工单图片才会发送至模型。"><div className="flex h-8 items-center gap-2"><Switch checked={editing.image_egress_approved} onCheckedChange={(checked) => setEditing({ ...editing, image_egress_approved: checked })} /><span className="text-sm text-muted-foreground">{editing.image_egress_approved ? '已授权' : '未授权'}</span></div></AppField>
</FieldGroup> : null}<DialogFooter><Button variant="outline" onClick={() => setEditing(null)}>取消</Button><Button onClick={() => void handleUpdateProvider()}>保存</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(editingDeployment)} onOpenChange={(open) => !open && setEditingDeployment(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型配置</DialogTitle></DialogHeader>{editingDeployment ? <FieldGroup>
<AppField label="显示名称"><Input value={editingDeployment.display_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, display_name: e.target.value })} /></AppField>
<AppField label="模型名"><Input value={editingDeployment.model_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, model_name: e.target.value })} /></AppField>
<AppField label="模型能力"><Select value={editingDeployment.capability} onValueChange={(value) => setEditingDeployment({ ...editingDeployment, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem></SelectContent></Select></AppField>
<AppField label="Temperature"><Input type="number" min="0" max="2" step="0.1" value={editingDeployment.default_temperature} onChange={(e) => setEditingDeployment({ ...editingDeployment, default_temperature: Number(e.target.value) })} /></AppField>
<AppField label="最大输出 Token"><Input type="number" min="1" max="32768" value={editingDeployment.max_output_tokens} onChange={(e) => setEditingDeployment({ ...editingDeployment, max_output_tokens: Number(e.target.value) })} /></AppField>
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editingDeployment.enabled} onCheckedChange={(checked) => setEditingDeployment({ ...editingDeployment, enabled: checked })} /><span className="text-sm text-muted-foreground">{editingDeployment.enabled ? '已启用' : '已停用'}</span></div></AppField>

View File

@@ -6,6 +6,9 @@ export type ModelProvider = {
default_timeout_seconds: number
credential_set: boolean
credential_masked: string
image_egress_approved: boolean
image_egress_approved_by: string | null
image_egress_approved_at: string | null
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,116 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TicketDraft } from './draft-types'
import { TicketCreatePage } from './TicketCreatePage'
const mocks = vi.hoisted(() => ({
mobile: false,
createTicket: vi.fn(),
availability: vi.fn(),
listDrafts: vi.fn(),
createDraft: vi.fn(),
analyzeDraft: vi.fn(),
getDraft: vi.fn(),
updateDraft: vi.fn(),
answerDraft: vi.fn(),
confirmDraft: vi.fn(),
rejectDraft: vi.fn(),
uploadImage: vi.fn(),
deleteImage: vi.fn()
}))
vi.mock('@/hooks/use-mobile', () => ({ useIsMobile: () => mocks.mobile }))
vi.mock('./api', () => ({ createTicket: mocks.createTicket }))
vi.mock('./draft-api', () => ({
getTicketDraftAvailability: mocks.availability,
listTicketDrafts: mocks.listDrafts,
createTicketDraft: mocks.createDraft,
analyzeTicketDraft: mocks.analyzeDraft,
getTicketDraft: mocks.getDraft,
updateTicketDraft: mocks.updateDraft,
answerTicketDraft: mocks.answerDraft,
confirmTicketDraft: mocks.confirmDraft,
rejectTicketDraft: mocks.rejectDraft,
uploadTicketDraftImage: mocks.uploadImage,
deleteTicketDraftImage: mocks.deleteImage
}))
function draft(status: TicketDraft['status'], revision: number): TicketDraft {
return {
public_id: 'draft-1',
status,
revision,
source_text: '生产采集任务未更新',
fields: status === 'ready' ? {
title: '生产采集任务未更新',
description: '生产环境采集任务两小时没有更新',
category_code: 'technical_issue',
priority: 'high',
problem_performance: '采集进度停滞',
problem_object: '任务 A',
submitter_name: '张三',
submitter_org: '华东分公司',
impact_scope: '单个任务',
environment: '生产环境',
related_system: '数据中台'
} : {},
last_ai_fields: {},
missing_fields: [],
questions: [],
guided_rounds: 0,
agent_run_public_id: null,
images: [],
messages: [],
decision: '',
error_code: '',
error_message: '',
confirmed_ticket: null,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
expires_at: '2026-07-17T00:00:00Z'
}
}
describe('可信 AI 建单页', () => {
afterEach(() => cleanup())
beforeEach(() => {
vi.clearAllMocks()
mocks.mobile = false
mocks.availability.mockResolvedValue({ available: true, reason: '', vision_available: false, vision_reason: '当前模型不支持图片' })
mocks.listDrafts.mockResolvedValue([])
mocks.createDraft.mockResolvedValue(draft('editing', 1))
mocks.analyzeDraft.mockResolvedValue(draft('ready', 2))
mocks.confirmDraft.mockResolvedValue({
...draft('confirmed', 3),
decision: 'accepted',
confirmed_ticket: { id: 19 }
})
})
it('必须经过结构化审核和显式确认才创建工单', async () => {
const user = userEvent.setup()
render(<TicketCreatePage />)
const source = await screen.findByPlaceholderText(/生产环境中/)
await user.type(source, '生产采集任务未更新')
await user.click(screen.getByRole('button', { name: '生成结构化草稿' }))
expect(await screen.findByText('最终确认')).toBeInTheDocument()
expect(mocks.confirmDraft).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: '确认创建工单' }))
await user.click(screen.getByRole('button', { name: '确认创建' }))
await waitFor(() => expect(mocks.confirmDraft).toHaveBeenCalledWith('draft-1', 2))
expect(mocks.createTicket).not.toHaveBeenCalled()
})
it('移动端只显示桌面端提示', () => {
mocks.mobile = true
render(<TicketCreatePage />)
expect(screen.getByText('请使用桌面端创建工单')).toBeInTheDocument()
expect(screen.queryByText('生成结构化草稿')).not.toBeInTheDocument()
})
})

View File

@@ -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></>
}

View File

@@ -0,0 +1,73 @@
import { apiRequest } from '@/lib/api'
import type { TicketInput } from './types'
import type { TicketDraft, TicketDraftAvailability } from './draft-types'
export function getTicketDraftAvailability() {
return apiRequest<TicketDraftAvailability>('/ticket-drafts/availability')
}
export function listTicketDrafts() {
return apiRequest<TicketDraft[]>('/ticket-drafts')
}
export function createTicketDraft(sourceText: string) {
return apiRequest<TicketDraft>('/ticket-drafts', {
method: 'POST',
body: JSON.stringify({ source_text: sourceText })
})
}
export function getTicketDraft(publicId: string) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}`)
}
export function updateTicketDraft(publicId: string, revision: number, fields: TicketInput) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}`, {
method: 'PATCH',
body: JSON.stringify({ expected_revision: revision, fields })
})
}
export function uploadTicketDraftImage(publicId: string, file: File) {
const body = new FormData()
body.append('file', file)
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/images`, {
method: 'POST',
body
})
}
export function deleteTicketDraftImage(publicId: string, imageId: string) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/images/${imageId}`, {
method: 'DELETE'
})
}
export function analyzeTicketDraft(publicId: string, revision: number) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/analyze`, {
method: 'POST',
body: JSON.stringify({ expected_revision: revision })
})
}
export function answerTicketDraft(publicId: string, revision: number, question: string, answer: string) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/answers`, {
method: 'POST',
body: JSON.stringify({ expected_revision: revision, question, answer })
})
}
export function confirmTicketDraft(publicId: string, revision: number) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/confirm`, {
method: 'POST',
body: JSON.stringify({ expected_revision: revision })
})
}
export function rejectTicketDraft(publicId: string, revision: number) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/reject`, {
method: 'POST',
body: JSON.stringify({ expected_revision: revision })
})
}

View File

@@ -0,0 +1,47 @@
import type { Ticket, TicketInput } from './types'
export type TicketDraftStatus = 'editing' | 'analyzing' | 'ready' | 'failed' | 'confirmed' | 'rejected' | 'expired'
export type TicketDraftImage = {
id: string
file_name: string
mime_type: string
file_size: number
status: string
}
export type TicketDraftMessage = {
role: 'assistant' | 'user'
content: string
round_number: number
created_at: string
}
export type TicketDraft = {
public_id: string
status: TicketDraftStatus
revision: number
source_text: string
fields: Partial<TicketInput>
last_ai_fields: Partial<TicketInput>
missing_fields: string[]
questions: string[]
guided_rounds: number
agent_run_public_id: string | null
images: TicketDraftImage[]
messages: TicketDraftMessage[]
decision: 'accepted' | 'modified' | 'rejected' | ''
error_code: string
error_message: string
confirmed_ticket: Ticket | null
created_at: string
updated_at: string
expires_at: string
}
export type TicketDraftAvailability = {
available: boolean
reason: string
vision_available: boolean
vision_reason: string
}

View File

@@ -43,7 +43,7 @@ async function getCsrfToken() {
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (init?.body && !headers.has('content-type')) {
if (init?.body && !(init.body instanceof FormData) && !headers.has('content-type')) {
headers.set('content-type', 'application/json')
}
const method = init?.method?.toUpperCase() ?? 'GET'