385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Descriptions,
|
|
Drawer,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import {
|
|
SUPPORT_TICKET_STATUS_LABELS,
|
|
SUPPORT_TICKET_TYPE_LABELS,
|
|
type SupportTicketDto,
|
|
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';
|
|
|
|
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] }),
|
|
);
|
|
|
|
export default function SupportTicketsPage() {
|
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
|
useAdminList<SupportTicketDto>('/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<SupportTicketDto | null>(null);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
const [acting, setActing] = useState(false);
|
|
const [rejectOpen, setRejectOpen] = useState(false);
|
|
const [createForm] = Form.useForm<{
|
|
ticketType: SupportTicketTypeDto;
|
|
title: string;
|
|
content?: string;
|
|
remark?: string;
|
|
}>();
|
|
const [rejectForm] = Form.useForm<{ rejectReason: string }>();
|
|
|
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
|
|
|
useEffect(() => {
|
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
|
}, []);
|
|
|
|
async function openDetail(id: string) {
|
|
setDetail(await request<SupportTicketDto>(`/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,
|
|
}),
|
|
});
|
|
message.success('技术支持工单已创建,等待最高管理员评审');
|
|
setCreateOpen(false);
|
|
createForm.resetFields();
|
|
reload();
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '创建失败');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
async function approve() {
|
|
if (!detail) return;
|
|
setActing(true);
|
|
try {
|
|
await request(`/admin/support-tickets/${detail.id}/approve`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
message.success('评审通过,已进入开发');
|
|
setDrawerOpen(false);
|
|
reload();
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '操作失败');
|
|
} finally {
|
|
setActing(false);
|
|
}
|
|
}
|
|
|
|
async function submitReject() {
|
|
if (!detail) return;
|
|
const values = await rejectForm.validateFields();
|
|
setActing(true);
|
|
try {
|
|
await request(`/admin/support-tickets/${detail.id}/reject`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ rejectReason: values.rejectReason.trim() }),
|
|
});
|
|
message.success('已驳回');
|
|
setRejectOpen(false);
|
|
rejectForm.resetFields();
|
|
setDrawerOpen(false);
|
|
reload();
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '操作失败');
|
|
} finally {
|
|
setActing(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<SupportTicketDto> = [
|
|
{ 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 type="primary" loading={acting} onClick={() => void approve()}>
|
|
评审通过
|
|
</Button>
|
|
<Button danger loading={acting} onClick={() => setRejectOpen(true)}>
|
|
驳回
|
|
</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>
|
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
|
创建工单
|
|
</Button>
|
|
</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 }}
|
|
pagination={{
|
|
current: page,
|
|
pageSize,
|
|
total: data?.total ?? 0,
|
|
showSizeChanger: true,
|
|
onChange: (p, ps) => {
|
|
setPage(p);
|
|
setPageSize(ps);
|
|
},
|
|
}}
|
|
/>
|
|
|
|
<Drawer
|
|
title="技术支持详情"
|
|
width={520}
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
extra={drawerExtra}
|
|
>
|
|
{detail && (
|
|
<Descriptions column={1} bordered size="small">
|
|
<Descriptions.Item label="工单号">{detail.ticketNo}</Descriptions.Item>
|
|
<Descriptions.Item label="类型">
|
|
{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="状态">
|
|
<Tag color={STATUS_COLOR[detail.status]}>
|
|
{SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status}
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="标题">{detail.title}</Descriptions.Item>
|
|
<Descriptions.Item label="内容">
|
|
<div style={{ whiteSpace: 'pre-wrap' }}>{detail.content || '—'}</div>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="创建人">{detail.creatorName}</Descriptions.Item>
|
|
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
|
<Descriptions.Item label="评审人">{detail.reviewerName || '—'}</Descriptions.Item>
|
|
<Descriptions.Item label="评审时间">
|
|
{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="驳回理由">{detail.rejectReason || '—'}</Descriptions.Item>
|
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
|
</Descriptions>
|
|
)}
|
|
</Drawer>
|
|
|
|
<Modal
|
|
title="创建技术支持工单"
|
|
open={createOpen}
|
|
onCancel={() => setCreateOpen(false)}
|
|
onOk={() => void submitCreate()}
|
|
confirmLoading={creating}
|
|
destroyOnClose
|
|
okText="提交"
|
|
>
|
|
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG' }}>
|
|
<Form.Item
|
|
name="ticketType"
|
|
label="类型"
|
|
rules={[{ required: true, message: '请选择类型' }]}
|
|
>
|
|
<Select options={TYPE_OPTIONS} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="title"
|
|
label="标题"
|
|
rules={[{ required: true, message: '请填写标题' }]}
|
|
>
|
|
<Input placeholder="简要描述问题或建议" maxLength={128} showCount />
|
|
</Form.Item>
|
|
<Form.Item name="content" label="详细说明">
|
|
<Input.TextArea rows={5} placeholder="复现步骤、期望结果等" maxLength={4000} showCount />
|
|
</Form.Item>
|
|
<Form.Item name="remark" label="备注">
|
|
<Input.TextArea rows={2} placeholder="可选" maxLength={512} showCount />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title="驳回技术支持工单"
|
|
open={rejectOpen}
|
|
onCancel={() => setRejectOpen(false)}
|
|
onOk={() => void submitReject()}
|
|
confirmLoading={acting}
|
|
destroyOnClose
|
|
okText="确认驳回"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Form form={rejectForm} layout="vertical">
|
|
<Form.Item
|
|
name="rejectReason"
|
|
label="驳回理由"
|
|
rules={[{ required: true, message: '请填写驳回理由' }]}
|
|
>
|
|
<Input.TextArea rows={4} placeholder="必填" maxLength={512} showCount />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|