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, SUPPORT_TICKET_PRIORITY_LABELS, mapSupportTicketTypeToDevPlanTask, type BatchReviewPreviewItem, type BatchReviewPreviewResponse, type DevPlanTaskTypeDto, type DevPlanVersionDto, type SupportTicketDto, type SupportTicketLinkedTaskDto, type SupportTicketPriorityDto, 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', PUBLISHED: 'cyan', }; 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 PRIORITY_COLOR: Record = { LOW: 'default', NORMAL: 'blue', HIGH: 'orange', URGENT: 'red', }; const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map( (value) => ({ value, label: SUPPORT_TICKET_PRIORITY_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); if (filters.priority) qs.set('priority', filters.priority); 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, priority: values.priority, 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 || '', priority: detail.priority ?? 'NORMAL', 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, priority: values.priority, 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: 'priority', width: 90, render: (p: SupportTicketPriorityDto) => ( {SUPPORT_TICKET_PRIORITY_LABELS[p] ?? p ?? '普通'} ), }, { title: '状态', dataIndex: 'status', width: 100, render: (s: SupportTicketStatusDto, row) => ( {SUPPORT_TICKET_STATUS_LABELS[s] ?? s} {row.releasedVersionNo ? ( {row.releasedVersionNo} ) : null} ), }, { 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); }} > {(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 }) => (