Files
dukang/apps/admin-web/src/pages/SupportTicketsPage.tsx
T
jacy 02d89e6385 feat(ops): kb doc edit, validation auto-tickets, support batch ops
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:08:40 +08:00

1133 lines
42 KiB
TypeScript

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<SupportTicketStatusDto, string> = {
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<HqProfile | null>(null);
const [filters, setFilters] = useState<Record<string, string>>({});
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<TicketRow>(
'/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<TicketRow | null>(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<BatchReviewPreviewItem[]>([]);
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<DevPlanVersionDto[]>([]);
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<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
async function openDetail(id: string) {
setDetail(await request<TicketRow>(`/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<BatchReviewPreviewResponse>('/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<TicketRow> = [
{ 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) => (
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
),
},
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(String(row.id))}>
详情
</Button>
),
},
];
const drawerExtra = (() => {
if (!detail) return null;
if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) {
return (
<Space>
<Button onClick={openEdit}>编辑</Button>
<Button type="primary" loading={acting} onClick={openReview}>
审批
</Button>
</Space>
);
}
if (detail.status === 'DEVELOPING') {
return (
<Button type="primary" loading={acting} onClick={() => void startTesting()}>
开发完成 · 转入测试
</Button>
);
}
if (detail.status === 'TESTING') {
return (
<Button type="primary" loading={acting} onClick={() => void pass()}>
测试通过
</Button>
);
}
return null;
})();
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
技术支持
</Typography.Title>
<Space>
{isSuperAdmin ? (
<>
<Button
disabled={!selectedRowKeys.length}
loading={batchCreateSaving}
onClick={() => void submitBatchCreateTasks()}
>
一键创建任务
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
一键发布
</Button>
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
批量审核
</Button>
<Button
disabled={!selectedRowKeys.length}
onClick={() => {
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
setBatchStatusOpen(true);
}}
>
批量改状态
</Button>
</>
) : null}
<Button type="primary" onClick={() => setCreateOpen(true)}>
创建工单
</Button>
</Space>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="ticketType" label="类型">
<Select allowClear style={{ width: 120 }} options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit">
筛选
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
rowSelection={
isSuperAdmin
? {
selectedRowKeys,
onChange: (keys, rows) => {
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);
},
}}
/>
<Drawer
title={
<Space>
<span>技术支持详情</span>
{detail && (
<>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{detail.ticketNo}
</Typography.Text>
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
</>
)}
</Space>
}
width={680}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={drawerExtra}
>
{detail && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* 状态 + 标签 */}
<Card
size="small"
styles={{ body: { padding: 16 } }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space size={12}>
<Tag
color={STATUS_COLOR[detail.status]}
style={{ fontSize: 14, padding: '2px 12px', lineHeight: '26px', borderRadius: 6 }}
>
{SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status}
</Tag>
{detail.status === 'REJECTED' && (
<Typography.Text type="danger">此工单已被驳回</Typography.Text>
)}
{detail.status === 'PASSED' && (
<Typography.Text type="success">此工单已测试通过</Typography.Text>
)}
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
创建于 {fmtTime(detail.createdAt)}
</Typography.Text>
</div>
</Card>
{/* 状态流转时间线 */}
<Card
size="small"
title={<Typography.Text strong style={{ fontSize: 14 }}>状态流转</Typography.Text>}
styles={{ body: { padding: '12px 16px' } }}
>
<Timeline
items={(() => {
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 = <CloseCircleFilled style={{ color: '#ff4d4f', fontSize: 14 }} />;
color = 'red';
} else if (isCurrent) {
if (step.key === 'TESTING') {
dot = <SyncOutlined style={{ color: '#722ed1', fontSize: 14 }} />;
} else {
dot = <ClockCircleFilled style={{ color: STATUS_COLOR[step.key], fontSize: 14 }} />;
}
} else if (isPast) {
dot = <CheckCircleFilled style={{ color: '#52c41a', fontSize: 14 }} />;
color = 'green';
}
return {
dot,
color,
children: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
style={{
fontWeight: isCurrent ? 600 : 400,
color: isPast || isCurrent ? undefined : '#bbb',
}}
>
{step.label}
</span>
{step.time && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(step.time)}
</Typography.Text>
)}
</div>
),
};
});
})()}
/>
</Card>
{/* 标题 & 内容 */}
<Card
size="small"
styles={{ body: { padding: 16 } }}
>
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
{detail.title}
</Typography.Title>
<div
style={{
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: '12px 16px',
whiteSpace: 'pre-wrap',
fontSize: 14,
lineHeight: 1.8,
color: '#333',
minHeight: detail.content ? undefined : 40,
}}
>
{detail.content || (
<Typography.Text type="secondary">暂无详细说明</Typography.Text>
)}
</div>
</Card>
{/* 基本信息 */}
<Card
size="small"
title={<Typography.Text strong style={{ fontSize: 14 }}>基本信息</Typography.Text>}
styles={{ body: { padding: '12px 16px' } }}
>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px 24px',
fontSize: 13,
}}
>
<div>
<Typography.Text type="secondary">创建人</Typography.Text>
<div style={{ marginTop: 2 }}>{detail.creatorName}</div>
</div>
<div>
<Typography.Text type="secondary">创建时间</Typography.Text>
<div style={{ marginTop: 2 }}>{fmtTime(detail.createdAt)}</div>
</div>
<div>
<Typography.Text type="secondary">评审人</Typography.Text>
<div style={{ marginTop: 2 }}>{detail.reviewerName || '—'}</div>
</div>
<div>
<Typography.Text type="secondary">评审时间</Typography.Text>
<div style={{ marginTop: 2 }}>{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}</div>
</div>
</div>
{detail.rejectReason && (
<div style={{ marginTop: 16 }}>
<Typography.Text type="danger" strong style={{ fontSize: 13 }}>
驳回理由
</Typography.Text>
<div
style={{
marginTop: 6,
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 6,
padding: '10px 14px',
fontSize: 13,
color: '#a8071a',
lineHeight: 1.7,
}}
>
{detail.rejectReason}
</div>
</div>
)}
{detail.remark && (
<div style={{ marginTop: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
备注
</Typography.Text>
<div style={{ marginTop: 4, color: '#555', fontSize: 13, lineHeight: 1.6 }}>
{detail.remark}
</div>
</div>
)}
{detail.attachmentUrls?.length ? (
<div style={{ marginTop: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
附件
</Typography.Text>
<Space wrap style={{ marginTop: 8 }}>
{detail.attachmentUrls.map((url) => (
<Image key={url} src={url} width={80} height={80} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</div>
) : null}
</Card>
{/* 关联开发任务 */}
{detail.linkedTasks?.length ? (
<Card
size="small"
title={
<Typography.Text strong style={{ fontSize: 14 }}>
关联开发任务
<Typography.Text type="secondary" style={{ marginLeft: 8, fontWeight: 400 }}>
{detail.linkedTasks.length}
</Typography.Text>
</Typography.Text>
}
styles={{ body: { padding: 0 } }}
>
{detail.linkedTasks.map((t, idx) => (
<div
key={t.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '10px 16px',
borderBottom: idx < detail.linkedTasks!.length - 1 ? '1px solid #f0f0f0' : 'none',
gap: 12,
}}
>
<Typography.Text
code
style={{ fontSize: 12, flexShrink: 0 }}
>
{t.taskNo}
</Typography.Text>
<Typography.Text
style={{ flex: 1, fontSize: 13 }}
ellipsis={{ tooltip: t.content }}
>
{t.content}
</Typography.Text>
<Tag style={{ margin: 0, flexShrink: 0 }}>{t.status}</Tag>
</div>
))}
</Card>
) : null}
</div>
)}
</Drawer>
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', attachmentUrls: [''] }}>
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount />
</Form.Item>
<Form.Item name="content" label="详细说明">
<Input.TextArea rows={5} maxLength={4000} showCount />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
添加附件
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form>
</Modal>
<Modal title="审批" open={reviewOpen} onCancel={() => setReviewOpen(false)} onOk={() => void submitReviewStep1()} confirmLoading={acting} destroyOnClose>
<Form form={reviewForm} layout="vertical">
<Form.Item name="decision" label="审批结果" rules={[{ required: true }]}>
<Radio.Group
onChange={(e) => setReviewDecision(e.target.value as 'APPROVE' | 'REJECT')}
options={[
{ value: 'APPROVE', label: '通过' },
{ value: 'REJECT', label: '驳回' },
]}
/>
</Form.Item>
{reviewDecision === 'REJECT' ? (
<Form.Item name="rejectReason" label="驳回理由" rules={[{ required: true }]}>
<Input.TextArea rows={3} maxLength={512} showCount />
</Form.Item>
) : (
<Form.Item name="note" label="附注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
)}
</Form>
</Modal>
<Modal title="创建开发任务" open={createTasksOpen} onCancel={() => setCreateTasksOpen(false)} onOk={() => void submitCreateTasks()} confirmLoading={acting} destroyOnClose width={640}>
<Typography.Paragraph type="secondary">审批通过需至少创建 1 条开发计划任务。</Typography.Paragraph>
<Form form={tasksForm} layout="vertical">
<Form.List name="tasks">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Space key={key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...rest} name={[name, 'content']} rules={[{ required: true }]} style={{ flex: 1 }}>
<Input.TextArea rows={2} placeholder="任务内容" />
</Form.Item>
<Form.Item {...rest} name={[name, 'type']} rules={[{ required: true }]}>
<Select style={{ width: 100 }} options={TASK_TYPE_OPTIONS} />
</Form.Item>
{fields.length > 1 ? (
<MinusCircleOutlined onClick={() => remove(name)} />
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add({ content: '', type: 'BUG' })} block icon={<PlusOutlined />}>
添加任务
</Button>
</>
)}
</Form.List>
<Form.Item name="dispatchToWecom" valuePropName="checked" style={{ marginTop: 16 }}>
<Checkbox>审批通过后发送到企微开发助手</Checkbox>
</Form.Item>
<Form.Item name="dispatchSupplement" label="派发补充说明">
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑工单"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => void submitEdit()}
confirmLoading={editSaving}
destroyOnClose
width={640}
>
<Form form={editForm} layout="vertical">
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount />
</Form.Item>
<Form.Item name="content" label="详细说明">
<Input.TextArea rows={5} maxLength={4000} showCount />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
添加附件
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form>
</Modal>
<Modal
title="批量改状态"
open={batchStatusOpen}
onCancel={() => setBatchStatusOpen(false)}
onOk={() => void submitBatchStatus()}
confirmLoading={batchStatusSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary">已选 {selectedRowKeys.length} 条工单</Typography.Paragraph>
<Form form={batchStatusForm} layout="vertical">
<Form.Item name="status" label="目标状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS.filter((o) => o.value !== 'PENDING_REVIEW')} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.status !== cur.status}>
{({ getFieldValue }) =>
getFieldValue('status') === 'REJECTED' ? (
<Form.Item name="rejectReason" label="驳回理由" rules={[{ required: true }]}>
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title="一键发布"
open={batchPublishOpen}
onCancel={() => setBatchPublishOpen(false)}
onOk={() => void submitBatchPublish()}
confirmLoading={batchPublishSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary">
已选 {selectedRowKeys.length} 条工单。将把各工单关联的开发任务追加到所选版本;无任务的工单将跳过。
</Typography.Paragraph>
<Form form={batchPublishForm} layout="vertical">
<Form.Item name="versionId" label="开发版本" rules={[{ required: true, message: '请选择开发版本' }]}>
<Select
placeholder="选择版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
<Form.Item name="dispatchToWecom" valuePropName="checked">
<Checkbox>同时发送到企微开发助手</Checkbox>
</Form.Item>
<Form.Item name="dispatchSupplement" label="派发补充说明">
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
</Form.Item>
</Form>
</Modal>
<Modal
title="批量 AI 审核确认"
open={batchPreviewOpen}
onCancel={() => setBatchPreviewOpen(false)}
onOk={() => void submitBatchConfirm()}
confirmLoading={batchConfirming}
width={900}
destroyOnClose
>
<Form form={batchForm} layout="vertical">
<Form.List name="items">
{(fields) => (
<>
{fields.map(({ key, name }) => {
const item = batchPreview[name];
if (!item) return null;
return (
<div key={key} style={{ marginBottom: 24, borderBottom: '1px solid #f0f0f0', paddingBottom: 16 }}>
<Typography.Text strong>
{item.ticketNo} · {item.title}
</Typography.Text>
<Form.Item name={[name, 'decision']} label="AI 建议">
<Radio.Group
options={[
{ value: 'APPROVE', label: '通过' },
{ value: 'REJECT', label: '驳回' },
]}
/>
</Form.Item>
<Form.Item name={[name, 'rejectReason']} label="驳回理由">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name={[name, 'note']} label="附注">
<Input.TextArea rows={2} />
</Form.Item>
<Typography.Paragraph type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{item.reportMarkdown}
</Typography.Paragraph>
<Form.List name={[name, 'suggestedTasks']}>
{(taskFields, { add, remove }) => (
<>
<Typography.Text>建议任务</Typography.Text>
{taskFields.map(({ key: tk, name: tn, ...rest }) => (
<Space key={tk} align="baseline" style={{ display: 'flex' }}>
<Form.Item {...rest} name={[tn, 'content']} rules={[{ required: true }]}>
<Input.TextArea rows={2} style={{ width: 400 }} />
</Form.Item>
<Form.Item {...rest} name={[tn, 'type']} rules={[{ required: true }]}>
<Select style={{ width: 100 }} options={TASK_TYPE_OPTIONS} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(tn)} />
</Space>
))}
<Button type="dashed" size="small" onClick={() => add({ content: '', type: 'BUG' })}>
添加任务
</Button>
</>
)}
</Form.List>
</div>
);
})}
</>
)}
</Form.List>
</Form>
</Modal>
</div>
);
}