368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
'use client'
|
||
import { useState } from 'react'
|
||
import { Button } from '@/components/ui'
|
||
import SelectWithInput from '@/components/ui/SelectWithInput'
|
||
import { Plus, Trash2 } from 'lucide-react'
|
||
|
||
interface TicketData {
|
||
id: number
|
||
assign_time: string | null
|
||
fault_category: string | null
|
||
fault_subcategory: string | null
|
||
responsibility: string | null
|
||
current_status: string
|
||
}
|
||
|
||
interface TimelineStep {
|
||
time_node: string
|
||
handler: string
|
||
description: string
|
||
}
|
||
|
||
interface ProcessFormProps {
|
||
ticket: TicketData
|
||
currentUserDisplayName: string
|
||
onSuccess: () => void
|
||
onCancel: () => void
|
||
}
|
||
|
||
const FAULT_CATEGORIES = [
|
||
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
|
||
]
|
||
|
||
const TICKET_TYPES = ['OEM诊断', 'OEM维修']
|
||
|
||
const FAULT_SUBCATEGORIES = [
|
||
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
|
||
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
|
||
'存储掉盘', 'RAID故障', '无故障', '其他',
|
||
]
|
||
|
||
const HANDLER_OPTIONS = ['腾讯', '图灵']
|
||
|
||
function beijingNow(): string {
|
||
const d = new Date(Date.now() + 8 * 60 * 60 * 1000)
|
||
return d.toISOString().slice(0, 10) + ' ' + d.toISOString().slice(11, 19)
|
||
}
|
||
|
||
const TIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/
|
||
|
||
export default function ProcessForm({ ticket, currentUserDisplayName, onSuccess, onCancel }: ProcessFormProps) {
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [closeTimeError, setCloseTimeError] = useState('')
|
||
const [stepTimeErrors, setStepTimeErrors] = useState<Record<number, string>>({})
|
||
|
||
// 工单类型
|
||
const [ticketType, setTicketType] = useState((ticket as any).ticket_type || '')
|
||
|
||
// 故障信息修正(in_progress 时预填已有值)
|
||
const [faultCategory, setFaultCategory] = useState(ticket.fault_category || '')
|
||
const [faultSubcategory, setFaultSubcategory] = useState(ticket.fault_subcategory || '')
|
||
const [responsibility, setResponsibility] = useState(ticket.responsibility || '')
|
||
|
||
// 结单信息
|
||
const [closeTime, setCloseTime] = useState(beijingNow())
|
||
const [processSummary, setProcessSummary] = useState('')
|
||
const [conclusion, setConclusion] = useState('')
|
||
const [partsReplaced, setPartsReplaced] = useState<'是' | '否'>('否')
|
||
const [partsName, setPartsName] = useState('')
|
||
|
||
// 处理时间线(默认至少一个步骤)
|
||
const [steps, setSteps] = useState<TimelineStep[]>([
|
||
{ time_node: beijingNow(), handler: '图灵', description: '' },
|
||
])
|
||
|
||
function addStep() {
|
||
setSteps(prev => [...prev, { time_node: beijingNow(), handler: '', description: '' }])
|
||
}
|
||
|
||
function removeStep(index: number) {
|
||
setSteps(prev => prev.filter((_, i) => i !== index))
|
||
}
|
||
|
||
function updateStep(index: number, field: keyof TimelineStep, value: string) {
|
||
setSteps(prev => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)))
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
setError('')
|
||
|
||
// 必填项校验
|
||
if (!faultCategory) {
|
||
setError('故障大类不能为空')
|
||
return
|
||
}
|
||
if (!faultSubcategory) {
|
||
setError('故障小类不能为空')
|
||
return
|
||
}
|
||
if (!closeTime) {
|
||
setError('结单时间不能为空')
|
||
return
|
||
}
|
||
if (closeTime && ticket.assign_time && closeTime < ticket.assign_time) {
|
||
setError('结单时间不能早于派单时间')
|
||
return
|
||
}
|
||
if (!processSummary.trim()) {
|
||
setError('处理结果不能为空')
|
||
return
|
||
}
|
||
if (!conclusion.trim()) {
|
||
setError('结论不能为空')
|
||
return
|
||
}
|
||
if (partsReplaced === '是' && !partsName.trim()) {
|
||
setError('选择更换配件后,配件名称不能为空')
|
||
return
|
||
}
|
||
if (closeTime && !TIME_RE.test(closeTime)) {
|
||
setError('结单时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
|
||
return
|
||
}
|
||
const badStep = steps.find(s => s.time_node && !TIME_RE.test(s.time_node))
|
||
if (badStep) {
|
||
setError(`处理步骤"${badStep.description.slice(0, 20)}"的时间节点格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式`)
|
||
return
|
||
}
|
||
// 至少一个处理步骤,且描述必填
|
||
const emptySteps = steps.filter(s => !s.description.trim())
|
||
if (emptySteps.length > 0) {
|
||
setError('每个处理步骤的描述不能为空')
|
||
return
|
||
}
|
||
|
||
if (!confirm(`确认提交处理?\n\n工单类型:${ticketType || '无'}\n故障大类:${faultCategory}\n结单时间:${closeTime}\n处理结果:${processSummary.slice(0, 50)}${processSummary.length > 50 ? '...' : ''}\n处理步骤:${steps.length} 步\n\n提交后将标记为"已解决"。`)) {
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
try {
|
||
const assignTime = ticket.assign_time ? new Date(ticket.assign_time).getTime() : 0
|
||
const closeMs = new Date(closeTime).getTime()
|
||
const durationMinutes = assignTime ? Math.round((closeMs - assignTime) / 60000) : 0
|
||
|
||
const body: Record<string, unknown> = {
|
||
ticket_type: ticketType || null,
|
||
fault_category: faultCategory || null,
|
||
fault_subcategory: faultSubcategory || null,
|
||
responsibility: responsibility || null,
|
||
close_time: closeTime,
|
||
process_summary: processSummary || null,
|
||
conclusion: conclusion || null,
|
||
parts_replaced: partsReplaced,
|
||
parts_name: partsReplaced === '是' ? partsName : null,
|
||
current_status: 'resolved',
|
||
duration_minutes: durationMinutes,
|
||
steps: steps.map(s => ({
|
||
time_node: s.time_node || null,
|
||
handler: s.handler || null,
|
||
description: s.description || null,
|
||
})),
|
||
}
|
||
|
||
const res = await fetch(`/api/tickets/${ticket.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json()
|
||
setError(data.error || '提交失败')
|
||
return
|
||
}
|
||
|
||
onSuccess()
|
||
} catch {
|
||
setError('网络错误')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-6 space-y-6">
|
||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">处理工单</h2>
|
||
|
||
{/* 故障信息修正 */}
|
||
<div className="space-y-4">
|
||
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700 pb-2">工单类型与故障信息</h3>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<SelectWithInput
|
||
label="工单类型"
|
||
value={ticketType}
|
||
onChange={setTicketType}
|
||
options={TICKET_TYPES}
|
||
placeholder="请选择或输入..."
|
||
/>
|
||
<SelectWithInput
|
||
label="责任方"
|
||
value={responsibility}
|
||
onChange={setResponsibility}
|
||
options={['腾讯', '图灵']}
|
||
placeholder="选择或输入责任方..."
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<SelectWithInput
|
||
label={<>故障大类 <span className="text-red-500">*</span></>}
|
||
value={faultCategory}
|
||
onChange={setFaultCategory}
|
||
options={FAULT_CATEGORIES}
|
||
placeholder="请选择或输入..."
|
||
/>
|
||
<SelectWithInput
|
||
label={<>故障小类 <span className="text-red-500">*</span></>}
|
||
value={faultSubcategory}
|
||
onChange={setFaultSubcategory}
|
||
options={FAULT_SUBCATEGORIES}
|
||
placeholder="请选择或输入..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 结单信息 */}
|
||
<div className="space-y-4">
|
||
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700 pb-2">结单信息</h3>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">结单时间 <span className="text-red-500">*</span></label>
|
||
<input
|
||
type="text"
|
||
className={`w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 ${closeTimeError ? 'border-red-500 bg-red-50 dark:bg-red-950/20' : 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100'}`}
|
||
value={closeTime}
|
||
onChange={e => { setCloseTime(e.target.value); setCloseTimeError('') }}
|
||
onBlur={() => {
|
||
if (closeTime && !TIME_RE.test(closeTime)) {
|
||
setCloseTimeError('时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
|
||
}
|
||
}}
|
||
placeholder="2026-05-02 18:34:36"
|
||
/>
|
||
{closeTimeError && <p className="text-xs text-red-500 mt-1">{closeTimeError}</p>}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">处理结果 <span className="text-red-500">*</span></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={processSummary}
|
||
onChange={e => setProcessSummary(e.target.value)}
|
||
placeholder="描述处理结果"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">结论 <span className="text-red-500">*</span></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={conclusion}
|
||
onChange={e => setConclusion(e.target.value)}
|
||
placeholder="处理结论..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">是否更换配件?</label>
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPartsReplaced('是')}
|
||
className={`px-4 py-1.5 rounded-lg text-sm border transition-colors ${partsReplaced === '是' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}
|
||
>是</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPartsReplaced('否')}
|
||
className={`px-4 py-1.5 rounded-lg text-sm border transition-colors ${partsReplaced === '否' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}
|
||
>否</button>
|
||
</div>
|
||
</div>
|
||
{partsReplaced === '是' && (
|
||
<div className="max-w-xs">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">配件名称 <span className="text-red-500">*</span></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"
|
||
value={partsName}
|
||
onChange={e => setPartsName(e.target.value)}
|
||
placeholder="输入更换的配件名称"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 处理时间线 */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between border-b border-slate-100 dark:border-slate-700 pb-2">
|
||
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400">处理时间线</h3>
|
||
<Button type="button" variant="secondary" size="sm" onClick={addStep}>
|
||
<Plus size={14} className="mr-1" />添加步骤
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 -mt-2">派单时间和结单时间已自动记录,此处仅填写中间处理步骤。</p>
|
||
{steps.length === 0 && (
|
||
<p className="text-sm text-slate-400 dark:text-slate-500">暂无步骤,请至少添加一个处理步骤。</p>
|
||
)}
|
||
{steps.map((step, idx) => (
|
||
<div key={idx} className="flex gap-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div className="flex flex-col items-center">
|
||
<div className="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-xs font-medium">{idx + 1}</div>
|
||
</div>
|
||
<div className="flex-1 space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">时间节点</label>
|
||
<input
|
||
type="text"
|
||
className={`w-full px-2 py-1.5 rounded border text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 ${stepTimeErrors[idx] ? 'border-red-500 bg-red-50 dark:bg-red-950/20' : 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100'}`}
|
||
value={step.time_node}
|
||
onChange={e => { updateStep(idx, 'time_node', e.target.value); setStepTimeErrors(prev => { const next = { ...prev }; delete next[idx]; return next }) }}
|
||
onBlur={() => {
|
||
if (step.time_node && !TIME_RE.test(step.time_node)) {
|
||
setStepTimeErrors(prev => ({ ...prev, [idx]: '时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式' }))
|
||
}
|
||
}}
|
||
placeholder="2026-05-02 18:34:36"
|
||
/>
|
||
{stepTimeErrors[idx] && <p className="text-xs text-red-500 mt-1">{stepTimeErrors[idx]}</p>}
|
||
</div>
|
||
<SelectWithInput
|
||
label="处理人"
|
||
value={step.handler}
|
||
onChange={val => updateStep(idx, 'handler', val)}
|
||
options={HANDLER_OPTIONS}
|
||
placeholder="选择或输入处理人..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">处理步骤 <span className="text-red-500">*</span></label>
|
||
<textarea
|
||
className="w-full px-2 py-1.5 rounded 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-[60px]"
|
||
value={step.description}
|
||
onChange={e => updateStep(idx, 'description', e.target.value)}
|
||
placeholder="描述此处理步骤..."
|
||
/>
|
||
</div>
|
||
{steps.length > 1 && (
|
||
<div className="flex justify-end">
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => removeStep(idx)}>
|
||
<Trash2 size={14} className="text-red-500 mr-1" />删除此步骤
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||
|
||
<div className="flex gap-3 pt-2">
|
||
<Button type="button" onClick={handleSubmit} disabled={loading}>
|
||
{loading ? '提交中...' : '提交处理'}
|
||
</Button>
|
||
<Button type="button" variant="secondary" onClick={onCancel}>取消</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|