import { useEffect, useState } from 'react'; import { Button, Card, Checkbox, Drawer, Form, Image, Input, Modal, Radio, Select, Space, Table, Tag, Timeline, Typography, message, } from 'antd'; import { CheckCircleFilled, CloseCircleFilled, ClockCircleFilled, MinusCircleOutlined, PlusOutlined, SyncOutlined, } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { DEV_PLAN_TASK_TYPE_LABELS, SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_TYPE_LABELS, mapSupportTicketTypeToDevPlanTask, type BatchReviewPreviewItem, type BatchReviewPreviewResponse, type DevPlanTaskTypeDto, type DevPlanVersionDto, type SupportTicketDto, type SupportTicketLinkedTaskDto, type SupportTicketStatusDto, type SupportTicketTypeDto, } from '@dukang/shared-types'; import { request, type HqProfile } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom'; const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程'; const STATUS_COLOR: Record = { PENDING_REVIEW: 'orange', REJECTED: 'red', DEVELOPING: 'blue', TESTING: 'purple', PASSED: 'green', }; const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map( (value) => ({ value, label: SUPPORT_TICKET_TYPE_LABELS[value] }), ); const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTicketStatusDto[]).map( (value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }), ); const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map( (v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }), ); type TicketRow = SupportTicketDto & { linkedTasks?: SupportTicketLinkedTaskDto[] }; export default function SupportTicketsPage() { const [profile, setProfile] = useState(null); const [filters, setFilters] = useState>({}); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/support-tickets', () => { const qs = new URLSearchParams(); if (filters.ticketType) qs.set('ticketType', filters.ticketType); if (filters.status) qs.set('status', filters.status); return qs; }, [filters], ); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [creating, setCreating] = useState(false); const [acting, setActing] = useState(false); const [reviewOpen, setReviewOpen] = useState(false); const [createTasksOpen, setCreateTasksOpen] = useState(false); const [batchPreviewOpen, setBatchPreviewOpen] = useState(false); const [batchPreview, setBatchPreview] = useState([]); const [batchConfirming, setBatchConfirming] = useState(false); const [batchStatusOpen, setBatchStatusOpen] = useState(false); const [batchStatusSaving, setBatchStatusSaving] = useState(false); const [batchCreateSaving, setBatchCreateSaving] = useState(false); const [batchPublishOpen, setBatchPublishOpen] = useState(false); const [batchPublishSaving, setBatchPublishSaving] = useState(false); const [versions, setVersions] = useState([]); const [editOpen, setEditOpen] = useState(false); const [editSaving, setEditSaving] = useState(false); const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE'); const [createForm] = Form.useForm(); const [editForm] = Form.useForm(); const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>(); const [tasksForm] = Form.useForm<{ tasks: Array<{ content: string; type: DevPlanTaskTypeDto }>; dispatchToWecom?: boolean; dispatchSupplement?: string; }>(); const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>(); const [batchStatusForm] = Form.useForm<{ status: SupportTicketStatusDto; rejectReason?: string; note?: string; }>(); const [batchPublishForm] = Form.useForm<{ versionId: string; dispatchToWecom?: boolean; dispatchSupplement?: string; }>(); const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; useEffect(() => { if (!batchPublishOpen) return; request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100') .then((res) => setVersions(res.items ?? [])) .catch(() => setVersions([])); }, [batchPublishOpen]); useEffect(() => { request('/admin/auth/me').then(setProfile).catch(() => {}); }, []); async function openDetail(id: string) { setDetail(await request(`/admin/support-tickets/${id}`)); setDrawerOpen(true); } async function submitCreate() { const values = await createForm.validateFields(); setCreating(true); try { await request('/admin/support-tickets', { method: 'POST', body: JSON.stringify({ ticketType: values.ticketType, title: values.title.trim(), content: values.content?.trim() || undefined, remark: values.remark?.trim() || undefined, attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), }), }); message.success('技术支持工单已创建,等待最高管理员评审'); setCreateOpen(false); createForm.resetFields(); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '创建失败'); } finally { setCreating(false); } } function openReview() { if (!detail) return; reviewForm.setFieldsValue({ decision: 'APPROVE', note: '', rejectReason: '' }); setReviewDecision('APPROVE'); setReviewOpen(true); } async function submitReviewStep1() { const values = await reviewForm.validateFields(); if (values.decision === 'REJECT') { if (!values.rejectReason?.trim()) { message.error('请填写驳回理由'); return; } setActing(true); try { await request(`/admin/support-tickets/${detail!.id}/review`, { method: 'POST', body: JSON.stringify({ decision: 'REJECT', rejectReason: values.rejectReason.trim() }), }); message.success('已驳回'); setReviewOpen(false); setDrawerOpen(false); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '操作失败'); } finally { setActing(false); } return; } setReviewOpen(false); const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500); tasksForm.setFieldsValue({ tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }], dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false', dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT, }); setCreateTasksOpen(true); } function openEdit() { if (!detail) return; editForm.setFieldsValue({ ticketType: detail.ticketType, title: detail.title, content: detail.content || '', remark: detail.remark || '', attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''], }); setEditOpen(true); } async function submitEdit() { const values = await editForm.validateFields(); setEditSaving(true); try { await request(`/admin/support-tickets/${detail!.id}`, { method: 'PATCH', body: JSON.stringify({ ticketType: values.ticketType, title: values.title.trim(), content: values.content?.trim() || undefined, remark: values.remark?.trim() || undefined, attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), }), }); message.success('已保存'); setEditOpen(false); await openDetail(String(detail!.id)); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '保存失败'); } finally { setEditSaving(false); } } async function submitBatchStatus() { const values = await batchStatusForm.validateFields(); setBatchStatusSaving(true); try { const res = await request<{ successCount: number; failCount: number }>( '/admin/support-tickets/batch-update-status', { method: 'POST', body: JSON.stringify({ ticketIds: selectedRowKeys, status: values.status, rejectReason: values.rejectReason?.trim() || undefined, note: values.note?.trim() || undefined, }), }, ); message.success(`成功 ${res.successCount} 条,失败 ${res.failCount} 条`); setBatchStatusOpen(false); setSelectedRowKeys([]); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '批量更新失败'); } finally { setBatchStatusSaving(false); } } async function submitBatchCreateTasks() { if (!selectedRowKeys.length) return; setBatchCreateSaving(true); try { const res = await request<{ successCount: number; failCount: number }>( '/admin/support-tickets/batch-create-tasks', { method: 'POST', body: JSON.stringify({ ticketIds: selectedRowKeys }), }, ); message.success(`已创建 ${res.successCount} 条任务,跳过/失败 ${res.failCount} 条`); setSelectedRowKeys([]); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '批量创建任务失败'); } finally { setBatchCreateSaving(false); } } function openBatchPublish() { batchPublishForm.setFieldsValue({ versionId: undefined, dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false', dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT, }); setBatchPublishOpen(true); } async function submitBatchPublish() { const values = await batchPublishForm.validateFields(); localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false'); setBatchPublishSaving(true); try { const res = await request<{ successCount: number; failCount: number; linkedTaskCount: number; }>('/admin/support-tickets/batch-publish', { method: 'POST', body: JSON.stringify({ ticketIds: selectedRowKeys, versionId: values.versionId, dispatchToWecom: !!values.dispatchToWecom, dispatchSupplement: values.dispatchSupplement?.trim() || undefined, }), }); message.success( `已关联 ${res.linkedTaskCount} 条任务到版本,工单成功 ${res.successCount} 条,失败 ${res.failCount} 条`, ); setBatchPublishOpen(false); setSelectedRowKeys([]); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '批量发布失败'); } finally { setBatchPublishSaving(false); } } async function submitCreateTasks() { const values = await tasksForm.validateFields(); const note = reviewForm.getFieldValue('note') as string | undefined; localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false'); setActing(true); try { await request(`/admin/support-tickets/${detail!.id}/review`, { method: 'POST', body: JSON.stringify({ decision: 'APPROVE', note: note?.trim() || undefined, tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })), dispatchToWecom: !!values.dispatchToWecom, dispatchSupplement: values.dispatchSupplement?.trim() || undefined, }), }); message.success(values.dispatchToWecom ? '审批通过,已创建任务并派发企微' : '审批通过,已创建开发任务'); setCreateTasksOpen(false); setDrawerOpen(false); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '操作失败'); } finally { setActing(false); } } async function startBatchPreview() { if (!selectedRowKeys.length) return; setActing(true); try { const res = await request('/admin/support-tickets/batch-review/preview', { method: 'POST', body: JSON.stringify({ ticketIds: selectedRowKeys }), }); setBatchPreview(res.items); batchForm.setFieldsValue({ items: res.items }); setBatchPreviewOpen(true); } catch (e) { message.error(e instanceof Error ? e.message : 'AI 预审失败'); } finally { setActing(false); } } async function submitBatchConfirm() { const values = await batchForm.validateFields(); setBatchConfirming(true); try { await request('/admin/support-tickets/batch-review/confirm', { method: 'POST', body: JSON.stringify({ items: values.items.map((item) => ({ ticketId: item.ticketId, decision: item.decision, rejectReason: item.rejectReason, note: item.note, tasks: item.decision === 'APPROVE' ? item.suggestedTasks : undefined, })), }), }); message.success('批量审批已完成'); setBatchPreviewOpen(false); setSelectedRowKeys([]); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '批量确认失败'); } finally { setBatchConfirming(false); } } async function startTesting() { if (!detail) return; setActing(true); try { await request(`/admin/support-tickets/${detail.id}/start-testing`, { method: 'POST', body: JSON.stringify({}), }); message.success('已转入测试'); setDrawerOpen(false); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '操作失败'); } finally { setActing(false); } } async function pass() { if (!detail) return; setActing(true); try { await request(`/admin/support-tickets/${detail.id}/pass`, { method: 'POST', body: JSON.stringify({}), }); message.success('测试通过'); setDrawerOpen(false); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '操作失败'); } finally { setActing(false); } } const columns: ColumnsType = [ { title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '类型', dataIndex: 'ticketType', width: 90, render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t, }, { title: '状态', dataIndex: 'status', width: 100, render: (s: SupportTicketStatusDto) => ( {SUPPORT_TICKET_STATUS_LABELS[s] ?? s} ), }, { title: '标题', dataIndex: 'title', ellipsis: true }, { title: '创建人', dataIndex: 'creatorName', width: 100 }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '操作', width: 80, render: (_, row) => ( ), }, ]; const drawerExtra = (() => { if (!detail) return null; if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) { return ( ); } if (detail.status === 'DEVELOPING') { return ( ); } if (detail.status === 'TESTING') { return ( ); } return null; })(); return (
技术支持 {isSuperAdmin ? ( <> ) : null}
{ setFilters(v); setPage(1); }} >
{ const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id)); setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending); }, getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }), } : undefined } pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, }} /> 技术支持详情 {detail && ( <> {detail.ticketNo} {SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} )} } width={680} open={drawerOpen} onClose={() => setDrawerOpen(false)} extra={drawerExtra} > {detail && (
{/* 状态 + 标签 */}
{SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status} {detail.status === 'REJECTED' && ( 此工单已被驳回 )} {detail.status === 'PASSED' && ( 此工单已测试通过 )} 创建于 {fmtTime(detail.createdAt)}
{/* 状态流转时间线 */} 状态流转} styles={{ body: { padding: '12px 16px' } }} > { const isRejected = detail.status === 'REJECTED'; const flowSteps: Array<{ key: SupportTicketStatusDto; time?: string | null; label: string }> = [ { key: 'PENDING_REVIEW', label: '待评审', time: detail.createdAt }, ...(!isRejected ? [ { key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt }, { key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null }, { key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt }, ] : [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]), ]; const statusIdx = flowSteps.findIndex((s) => s.key === detail.status); return flowSteps.map((step, idx) => { const isCurrent = step.key === detail.status; const isPast = !isRejected ? idx < statusIdx : step.key === 'PENDING_REVIEW' && statusIdx >= 1; const isRejectedStep = step.key === 'REJECTED'; let dot: React.ReactNode; let color: string | undefined; if (isRejectedStep) { dot = ; color = 'red'; } else if (isCurrent) { if (step.key === 'TESTING') { dot = ; } else { dot = ; } } else if (isPast) { dot = ; color = 'green'; } return { dot, color, children: (
{step.label} {step.time && ( {fmtTime(step.time)} )}
), }; }); })()} />
{/* 标题 & 内容 */} {detail.title}
{detail.content || ( 暂无详细说明 )}
{/* 基本信息 */} 基本信息} styles={{ body: { padding: '12px 16px' } }} >
创建人
{detail.creatorName}
创建时间
{fmtTime(detail.createdAt)}
评审人
{detail.reviewerName || '—'}
评审时间
{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}
{detail.rejectReason && (
驳回理由
{detail.rejectReason}
)} {detail.remark && (
备注
{detail.remark}
)} {detail.attachmentUrls?.length ? (
附件 {detail.attachmentUrls.map((url) => ( ))}
) : null}
{/* 关联开发任务 */} {detail.linkedTasks?.length ? ( 关联开发任务 {detail.linkedTasks.length} } styles={{ body: { padding: 0 } }} > {detail.linkedTasks.map((t, idx) => (
{t.taskNo} {t.content} {t.status}
))}
) : null}
)}
setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
{(fields, { add, remove }) => ( <> {fields.map((field) => ( {fields.length > 1 ? ( remove(field.name)} style={{ marginTop: 8 }} /> ) : null} ))} )}
setReviewOpen(false)} onOk={() => void submitReviewStep1()} confirmLoading={acting} destroyOnClose>
setReviewDecision(e.target.value as 'APPROVE' | 'REJECT')} options={[ { value: 'APPROVE', label: '通过' }, { value: 'REJECT', label: '驳回' }, ]} /> {reviewDecision === 'REJECT' ? ( ) : ( )}
setCreateTasksOpen(false)} onOk={() => void submitCreateTasks()} confirmLoading={acting} destroyOnClose width={640}> 审批通过需至少创建 1 条开发计划任务。
{(fields, { add, remove }) => ( <> {fields.map(({ key, name, ...rest }) => ( {(fields, { add, remove }) => ( <> {fields.map((field) => ( {fields.length > 1 ? ( remove(field.name)} style={{ marginTop: 8 }} /> ) : null} ))} )}
setBatchStatusOpen(false)} onOk={() => void submitBatchStatus()} confirmLoading={batchStatusSaving} destroyOnClose > 已选 {selectedRowKeys.length} 条工单
({ value: v.id, label: v.versionNo }))} /> 同时发送到企微开发助手
setBatchPreviewOpen(false)} onOk={() => void submitBatchConfirm()} confirmLoading={batchConfirming} width={900} destroyOnClose >
{(fields) => ( <> {fields.map(({ key, name }) => { const item = batchPreview[name]; if (!item) return null; return (
{item.ticketNo} · {item.title} {item.reportMarkdown} {(taskFields, { add, remove }) => ( <> 建议任务 {taskFields.map(({ key: tk, name: tn, ...rest }) => (