232 lines
11 KiB
TypeScript
232 lines
11 KiB
TypeScript
'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>
|
||
)
|
||
}
|