issue-ai/src/components/tickets/TicketForm.tsx

232 lines
11 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.

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Select } from '@/components/ui'
import { Search } from 'lucide-react'
import SelectWithInput from '@/components/ui/SelectWithInput'
interface TicketFormProps {
initialData?: Record<string, unknown>
ticketId?: number
}
const defaultFaultCategories = [
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
]
const defaultTicketTypes = ['OEM诊断', 'OEM维修']
const defaultFaultSubcategories = [
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
'存储掉盘', 'RAID故障', '无故障', '其他',
]
export default function TicketForm({ initialData, ticketId }: TicketFormProps) {
const router = useRouter()
const isEdit = !!ticketId
const timeRe = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [assignTimeError, setAssignTimeError] = useState('')
const [closeTimeError, setCloseTimeError] = useState('')
const [lookingUp, setLookingUp] = useState(false)
const [form, setForm] = useState({
ticket_no: String(initialData?.id || ''),
device_ip: (initialData?.device_ip as string) || '',
device_sn: (initialData?.device_sn as string) || '',
device_name: (initialData?.device_name as string) || '',
content: (initialData?.content as string) || '',
assign_time: (initialData?.assign_time as string) || (() => { const d = new Date(Date.now() + 8 * 60 * 60 * 1000); return d.toISOString().slice(0, 10) + ' ' + d.toISOString().slice(11, 19); })(),
ticket_type: (initialData?.ticket_type as string) || '',
fault_category: (initialData?.fault_category as string) || '',
fault_subcategory: (initialData?.fault_subcategory as string) || '',
responsibility: (initialData?.responsibility as string) || '',
current_status: (initialData?.current_status as string) || 'open',
counted_in_sla: (initialData?.counted_in_sla as number) ?? 1,
close_time: (initialData?.close_time as string) || '',
duration_minutes: (initialData?.duration_minutes as number) || '',
process_summary: (initialData?.process_summary as string) || '',
conclusion: (initialData?.conclusion as string) || '',
parts_replaced: (initialData?.parts_replaced as string) || '',
parts_name: (initialData?.parts_name as string) || '',
})
const handleIpLookup = async () => {
if (!form.device_ip) return
setLookingUp(true)
try {
const res = await fetch(`/api/assets/lookup?ip=${encodeURIComponent(form.device_ip)}`)
if (res.ok) {
const data = await res.json()
if (data.asset) {
setForm(prev => ({
...prev,
device_name: data.asset.node_name || prev.device_name,
device_sn: data.asset.serial_number || prev.device_sn,
}))
}
}
} catch {
// ignore
} finally {
setLookingUp(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (form.assign_time && !timeRe.test(form.assign_time)) {
setError('派单时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
return
}
if (form.close_time && !timeRe.test(form.close_time)) {
setError('结单时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
return
}
setError('')
setLoading(true)
try {
const url = isEdit ? `/api/tickets/${ticketId}` : '/api/tickets'
const method = isEdit ? 'PUT' : 'POST'
const body: Record<string, unknown> = { ...form }
if (body.duration_minutes === '') delete body.duration_minutes
else body.duration_minutes = Number(body.duration_minutes)
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = await res.json()
if (!res.ok) { setError(data.error || '操作失败'); return }
router.push(isEdit ? `/tickets/${ticketId}` : '/tickets/pending')
router.refresh()
} catch {
setError('网络错误')
} finally {
setLoading(false)
}
}
const update = (key: string, value: string | number) => setForm(prev => ({ ...prev, [key]: value }))
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
{/* 工单号独占一行 */}
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<input
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
value={form.ticket_no}
onChange={(e) => update('ticket_no', e.target.value)}
placeholder="14位工单号如 20260420039303"
disabled={isEdit}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">IP</label>
<div className="flex gap-2">
<input
className="flex-1 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
value={form.device_ip}
onChange={(e) => update('device_ip', e.target.value)}
placeholder="输入设备 IP"
/>
<Button type="button" variant="secondary" size="sm" onClick={handleIpLookup} disabled={lookingUp}>
<Search size={14} />
</Button>
</div>
</div>
<Input label="节点名称" value={form.device_name} onChange={(e) => update('device_name', e.target.value)} placeholder="自动填充或手动输入" />
<Input label="设备序列号" value={form.device_sn} onChange={(e) => update('device_sn', e.target.value)} placeholder="自动填充或手动输入" />
<SelectWithInput
label="工单类型"
value={form.ticket_type}
onChange={val => update('ticket_type', val)}
options={defaultTicketTypes}
placeholder="请选择或输入工单类型..."
/>
<SelectWithInput
label="故障大类"
value={form.fault_category}
onChange={val => update('fault_category', val)}
options={defaultFaultCategories}
placeholder="请选择或输入故障大类..."
/>
<SelectWithInput
label="故障小类"
value={form.fault_subcategory}
onChange={val => update('fault_subcategory', val)}
options={defaultFaultSubcategories}
placeholder="请选择或输入故障小类..."
/>
<Input label="责任方" value={form.responsibility} onChange={(e) => update('responsibility', e.target.value)} />
<Input label="派单时间" type="text" value={form.assign_time} onChange={(e) => { update('assign_time', e.target.value); setAssignTimeError('') }} onBlur={() => { if (form.assign_time && !timeRe.test(form.assign_time)) setAssignTimeError('时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式') }} placeholder="2026-05-02 18:34:36" error={assignTimeError} />
<Select
label="当前状态"
options={[
{ value: 'open', label: '待处理' },
{ value: 'in_progress', label: '处理中' },
{ value: 'resolved', label: '已解决' },
{ value: 'closed', label: '已关闭' },
]}
value={form.current_status}
onChange={(e) => update('current_status', e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<textarea
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 min-h-[100px]"
value={form.content}
onChange={(e) => update('content', e.target.value)}
placeholder="描述故障现象..."
/>
</div>
{isEdit && (
<div className="grid grid-cols-2 gap-4 border-t border-slate-200 dark:border-slate-700 pt-4">
<Input label="结单时间" type="text" value={form.close_time} onChange={(e) => { update('close_time', e.target.value); setCloseTimeError('') }} onBlur={() => { if (form.close_time && !timeRe.test(form.close_time)) setCloseTimeError('时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式') }} placeholder="2026-05-02 18:34:36" error={closeTimeError} />
<Input label="处理时长(分钟)" type="number" value={String(form.duration_minutes)} onChange={(e) => update('duration_minutes', e.target.value)} />
<div className="col-span-2 space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<textarea
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 min-h-[80px]"
value={form.process_summary}
onChange={(e) => update('process_summary', e.target.value)}
/>
</div>
<div className="col-span-2 space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<textarea
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 min-h-[80px]"
value={form.conclusion}
onChange={(e) => update('conclusion', e.target.value)}
/>
</div>
<Input label="更换配件" value={form.parts_replaced} onChange={(e) => update('parts_replaced', e.target.value)} />
<Input label="配件名称" value={form.parts_name} onChange={(e) => update('parts_name', e.target.value)} />
<Select
label="SLA 计数"
options={[
{ value: '1', label: '是' },
{ value: '0', label: '否' },
]}
value={String(form.counted_in_sla)}
onChange={(e) => update('counted_in_sla', parseInt(e.target.value))}
/>
</div>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3">
<Button type="submit" disabled={loading}>{loading ? '保存中...' : isEdit ? '保存修改' : '创建工单'}</Button>
<Button type="button" variant="secondary" onClick={() => router.back()}></Button>
</div>
</form>
)
}