02d89e6385
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>
1133 lines
42 KiB
TypeScript
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>
|
|
);
|
|
}
|