feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,800 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
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 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';
|
||||
|
||||
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 [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE');
|
||||
|
||||
const [createForm] = Form.useForm();
|
||||
const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>();
|
||||
const [tasksForm] = Form.useForm<{ tasks: Array<{ content: string; type: DevPlanTaskTypeDto }> }>();
|
||||
const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>();
|
||||
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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) }],
|
||||
});
|
||||
setCreateTasksOpen(true);
|
||||
}
|
||||
|
||||
async function submitCreateTasks() {
|
||||
const values = await tasksForm.validateFields();
|
||||
const note = reviewForm.getFieldValue('note') as string | undefined;
|
||||
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 })),
|
||||
}),
|
||||
});
|
||||
message.success('审批通过,已创建开发任务');
|
||||
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 (
|
||||
<Button type="primary" loading={acting} onClick={openReview}>
|
||||
审批
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
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={acting} onClick={() => void startBatchPreview()}>
|
||||
批量审核
|
||||
</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>
|
||||
)}
|
||||
</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' }}>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user