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

368 lines
16 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 { 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>
)
}