@@ -32,6 +32,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
|
|||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import WineryBillsPage from './pages/WineryBillsPage';
|
import WineryBillsPage from './pages/WineryBillsPage';
|
||||||
import TicketsPage from './pages/TicketsPage';
|
import TicketsPage from './pages/TicketsPage';
|
||||||
|
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||||
import InvoicesPage from './pages/InvoicesPage';
|
import InvoicesPage from './pages/InvoicesPage';
|
||||||
import UserLogsPage from './pages/UserLogsPage';
|
import UserLogsPage from './pages/UserLogsPage';
|
||||||
import HqLogsPage from './pages/HqLogsPage';
|
import HqLogsPage from './pages/HqLogsPage';
|
||||||
@@ -92,6 +93,7 @@ export default function App() {
|
|||||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
|
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||||
<Route path="/invoices" element={<InvoicesPage />} />
|
<Route path="/invoices" element={<InvoicesPage />} />
|
||||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||||
|
|||||||
@@ -94,7 +94,15 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
{
|
||||||
|
key: 'tickets-group',
|
||||||
|
icon: <CarOutlined />,
|
||||||
|
label: '工单',
|
||||||
|
children: [
|
||||||
|
{ key: '/tickets', label: '工单中心' },
|
||||||
|
{ key: '/tickets/support', label: '技术支持' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
@@ -115,6 +123,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||||
|
if (key === 'tickets-group') {
|
||||||
|
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||||
|
}
|
||||||
const map: Record<string, string | 'system_settings_any'> = {
|
const map: Record<string, string | 'system_settings_any'> = {
|
||||||
'/': 'dashboard',
|
'/': 'dashboard',
|
||||||
'/users': 'users',
|
'/users': 'users',
|
||||||
@@ -148,6 +159,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/deliveries': 'deliveries',
|
'/deliveries': 'deliveries',
|
||||||
'/deliveries/xiaofeixia': 'deliveries',
|
'/deliveries/xiaofeixia': 'deliveries',
|
||||||
'/tickets': 'tickets',
|
'/tickets': 'tickets',
|
||||||
|
'/tickets/support': 'tech_support',
|
||||||
'/invoices': 'invoices',
|
'/invoices': 'invoices',
|
||||||
'/resources': 'resources',
|
'/resources': 'resources',
|
||||||
'logs-group': 'logs',
|
'logs-group': 'logs',
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
||||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||||
{ value: 'TICKET_CREATE', label: '创建工单' },
|
{ value: 'TICKET_CREATE', label: '创建工单' },
|
||||||
|
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
|
||||||
|
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
|
||||||
|
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
|
||||||
|
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
|
||||||
|
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
|
||||||
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
||||||
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
||||||
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'benefit', label: '好客权益', group: '业务' },
|
{ key: 'benefit', label: '好客权益', group: '业务' },
|
||||||
{ key: 'deliveries', label: '配送单', group: '业务' },
|
{ key: 'deliveries', label: '配送单', group: '业务' },
|
||||||
{ key: 'tickets', label: '工单中心', group: '业务' },
|
{ key: 'tickets', label: '工单中心', group: '业务' },
|
||||||
|
{ key: 'tech_support', label: '技术支持', group: '业务' },
|
||||||
{ key: 'invoices', label: '发票管理', group: '业务' },
|
{ key: 'invoices', label: '发票管理', group: '业务' },
|
||||||
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||||
{ key: 'logs', label: '日志', group: '业务' },
|
{ key: 'logs', label: '日志', group: '业务' },
|
||||||
@@ -85,6 +86,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'benefit',
|
'benefit',
|
||||||
'deliveries',
|
'deliveries',
|
||||||
'tickets',
|
'tickets',
|
||||||
|
'tech_support',
|
||||||
'invoices',
|
'invoices',
|
||||||
'resources',
|
'resources',
|
||||||
'logs',
|
'logs',
|
||||||
@@ -97,9 +99,18 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'partners',
|
'partners',
|
||||||
'finance',
|
'finance',
|
||||||
'benefit',
|
'benefit',
|
||||||
|
'tech_support',
|
||||||
'invoices',
|
'invoices',
|
||||||
'logs',
|
'logs',
|
||||||
'system_settings_winery_bank',
|
'system_settings_winery_bank',
|
||||||
],
|
],
|
||||||
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
|
CUSTOMER_SERVICE: [
|
||||||
|
'dashboard',
|
||||||
|
'users',
|
||||||
|
'orders',
|
||||||
|
'tickets',
|
||||||
|
'tech_support',
|
||||||
|
'invoices',
|
||||||
|
'logs',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export * from './redeem';
|
|||||||
export * from './settlement';
|
export * from './settlement';
|
||||||
export * from './ops';
|
export * from './ops';
|
||||||
export * from './ticket';
|
export * from './ticket';
|
||||||
|
export * from './support-ticket';
|
||||||
export * from './invoice';
|
export * from './invoice';
|
||||||
export * from './user-log';
|
export * from './user-log';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/** HQ 技术支持工单类型 */
|
||||||
|
export type SupportTicketTypeDto = 'BUG' | 'SUGGESTION' | 'OTHER';
|
||||||
|
|
||||||
|
export const SUPPORT_TICKET_TYPES = ['BUG', 'SUGGESTION', 'OTHER'] as const;
|
||||||
|
|
||||||
|
export const SUPPORT_TICKET_TYPE_LABELS: Record<SupportTicketTypeDto, string> = {
|
||||||
|
BUG: 'BUG',
|
||||||
|
SUGGESTION: '建议',
|
||||||
|
OTHER: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 技术支持工单状态机 */
|
||||||
|
export type SupportTicketStatusDto =
|
||||||
|
| 'PENDING_REVIEW'
|
||||||
|
| 'REJECTED'
|
||||||
|
| 'DEVELOPING'
|
||||||
|
| 'TESTING'
|
||||||
|
| 'PASSED';
|
||||||
|
|
||||||
|
export const SUPPORT_TICKET_STATUSES = [
|
||||||
|
'PENDING_REVIEW',
|
||||||
|
'REJECTED',
|
||||||
|
'DEVELOPING',
|
||||||
|
'TESTING',
|
||||||
|
'PASSED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = {
|
||||||
|
PENDING_REVIEW: '待评审',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
DEVELOPING: '开发',
|
||||||
|
TESTING: '测试',
|
||||||
|
PASSED: '通过',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SupportTicketDto {
|
||||||
|
id: string;
|
||||||
|
ticketNo: string;
|
||||||
|
ticketType: SupportTicketTypeDto;
|
||||||
|
status: SupportTicketStatusDto;
|
||||||
|
title: string;
|
||||||
|
content?: string | null;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
creatorId: string;
|
||||||
|
creatorName: string;
|
||||||
|
reviewerId?: string | null;
|
||||||
|
reviewerName?: string | null;
|
||||||
|
reviewedAt?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSupportTicketRequest {
|
||||||
|
ticketType: SupportTicketTypeDto;
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RejectSupportTicketRequest {
|
||||||
|
rejectReason: string;
|
||||||
|
}
|
||||||
@@ -101,6 +101,31 @@ CREATE TABLE common_ticket (
|
|||||||
KEY idx_common_ticket_type_status (ticket_type, status)
|
KEY idx_common_ticket_type_status (ticket_type, status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用工单表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用工单表';
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS common_support_ticket;
|
||||||
|
CREATE TABLE common_support_ticket (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
ticket_no VARCHAR(32) NOT NULL,
|
||||||
|
ticket_type VARCHAR(32) NOT NULL COMMENT 'BUG|SUGGESTION|OTHER',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT 'PENDING_REVIEW|REJECTED|DEVELOPING|TESTING|PASSED',
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
content TEXT DEFAULT NULL,
|
||||||
|
reject_reason VARCHAR(512) DEFAULT NULL,
|
||||||
|
creator_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
creator_name VARCHAR(64) NOT NULL,
|
||||||
|
reviewer_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
reviewer_name VARCHAR(64) DEFAULT NULL,
|
||||||
|
reviewed_at DATETIME(3) DEFAULT NULL,
|
||||||
|
remark VARCHAR(512) DEFAULT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
completed_at DATETIME(3) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_common_support_ticket_no (ticket_no),
|
||||||
|
KEY idx_common_support_ticket_status (status, created_at),
|
||||||
|
KEY idx_common_support_ticket_type_status (ticket_type, status),
|
||||||
|
KEY idx_common_support_ticket_creator (creator_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HQ技术支持工单';
|
||||||
|
|
||||||
DROP TABLE IF EXISTS common_product_item;
|
DROP TABLE IF EXISTS common_product_item;
|
||||||
CREATE TABLE common_product_item (
|
CREATE TABLE common_product_item (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ enum TicketType {
|
|||||||
RETURN_REFUND
|
RETURN_REFUND
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SupportTicketType {
|
||||||
|
BUG
|
||||||
|
SUGGESTION
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SupportTicketStatus {
|
||||||
|
PENDING_REVIEW
|
||||||
|
REJECTED
|
||||||
|
DEVELOPING
|
||||||
|
TESTING
|
||||||
|
PASSED
|
||||||
|
}
|
||||||
|
|
||||||
enum InvoiceTitleType {
|
enum InvoiceTitleType {
|
||||||
PERSONAL
|
PERSONAL
|
||||||
ENTERPRISE
|
ENTERPRISE
|
||||||
@@ -440,6 +454,31 @@ model CommonTicket {
|
|||||||
@@map("common_ticket")
|
@@map("common_ticket")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HQ 内部技术支持工单(BUG / 建议 / 其他)
|
||||||
|
model CommonSupportTicket {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
ticketNo String @unique @map("ticket_no") @db.VarChar(32)
|
||||||
|
ticketType SupportTicketType @map("ticket_type")
|
||||||
|
status SupportTicketStatus @default(PENDING_REVIEW)
|
||||||
|
title String @db.VarChar(128)
|
||||||
|
content String? @db.Text
|
||||||
|
rejectReason String? @map("reject_reason") @db.VarChar(512)
|
||||||
|
creatorId BigInt @map("creator_id") @db.UnsignedBigInt
|
||||||
|
creatorName String @map("creator_name") @db.VarChar(64)
|
||||||
|
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
|
||||||
|
reviewerName String? @map("reviewer_name") @db.VarChar(64)
|
||||||
|
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
|
||||||
|
remark String? @db.VarChar(512)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([ticketType, status])
|
||||||
|
@@index([creatorId])
|
||||||
|
@@map("common_support_ticket")
|
||||||
|
}
|
||||||
|
|
||||||
model CommonProductItem {
|
model CommonProductItem {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export const HqOperationAction = {
|
|||||||
TICKET_APPROVE: 'TICKET_APPROVE',
|
TICKET_APPROVE: 'TICKET_APPROVE',
|
||||||
TICKET_REJECT: 'TICKET_REJECT',
|
TICKET_REJECT: 'TICKET_REJECT',
|
||||||
TICKET_CREATE: 'TICKET_CREATE',
|
TICKET_CREATE: 'TICKET_CREATE',
|
||||||
|
SUPPORT_TICKET_CREATE: 'SUPPORT_TICKET_CREATE',
|
||||||
|
SUPPORT_TICKET_APPROVE: 'SUPPORT_TICKET_APPROVE',
|
||||||
|
SUPPORT_TICKET_REJECT: 'SUPPORT_TICKET_REJECT',
|
||||||
|
SUPPORT_TICKET_START_TESTING: 'SUPPORT_TICKET_START_TESTING',
|
||||||
|
SUPPORT_TICKET_PASS: 'SUPPORT_TICKET_PASS',
|
||||||
INVOICE_CREATE: 'INVOICE_CREATE',
|
INVOICE_CREATE: 'INVOICE_CREATE',
|
||||||
INVOICE_ISSUE: 'INVOICE_ISSUE',
|
INVOICE_ISSUE: 'INVOICE_ISSUE',
|
||||||
INVOICE_REJECT: 'INVOICE_REJECT',
|
INVOICE_REJECT: 'INVOICE_REJECT',
|
||||||
@@ -124,6 +129,11 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.TICKET_APPROVE]: '工单通过',
|
[HqOperationAction.TICKET_APPROVE]: '工单通过',
|
||||||
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
||||||
[HqOperationAction.TICKET_CREATE]: '创建工单',
|
[HqOperationAction.TICKET_CREATE]: '创建工单',
|
||||||
|
[HqOperationAction.SUPPORT_TICKET_CREATE]: '创建技术支持工单',
|
||||||
|
[HqOperationAction.SUPPORT_TICKET_APPROVE]: '技术支持评审通过',
|
||||||
|
[HqOperationAction.SUPPORT_TICKET_REJECT]: '技术支持评审驳回',
|
||||||
|
[HqOperationAction.SUPPORT_TICKET_START_TESTING]: '技术支持转入测试',
|
||||||
|
[HqOperationAction.SUPPORT_TICKET_PASS]: '技术支持测试通过',
|
||||||
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
|
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
|
||||||
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
|
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
|
||||||
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
|
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SystemConfigModule } from '../../common/system-config/system-config.mod
|
|||||||
import { ResourceService } from './resource.service';
|
import { ResourceService } from './resource.service';
|
||||||
import { EventService } from './event.service';
|
import { EventService } from './event.service';
|
||||||
import { TicketService } from './ticket.service';
|
import { TicketService } from './ticket.service';
|
||||||
|
import { SupportTicketService } from './support-ticket.service';
|
||||||
import { ThirdPartyLogService } from './third-party-log.service';
|
import { ThirdPartyLogService } from './third-party-log.service';
|
||||||
import { ResourceController } from './resource.controller';
|
import { ResourceController } from './resource.controller';
|
||||||
import { EventController } from './event.controller';
|
import { EventController } from './event.controller';
|
||||||
@@ -25,7 +26,14 @@ import { WechatLocationService } from './wechat-location.service';
|
|||||||
WechatController,
|
WechatController,
|
||||||
ClientConfigController,
|
ClientConfigController,
|
||||||
],
|
],
|
||||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService, WechatLocationService],
|
providers: [
|
||||||
exports: [ResourceService, EventService, TicketService],
|
ResourceService,
|
||||||
|
EventService,
|
||||||
|
TicketService,
|
||||||
|
SupportTicketService,
|
||||||
|
ThirdPartyLogService,
|
||||||
|
WechatLocationService,
|
||||||
|
],
|
||||||
|
exports: [ResourceService, EventService, TicketService, SupportTicketService],
|
||||||
})
|
})
|
||||||
export class CommonModule {}
|
export class CommonModule {}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class SupportTicketListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||||
|
ticketType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED'])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateSupportTicketDto {
|
||||||
|
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||||
|
ticketType: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(128)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectSupportTicketDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(512)
|
||||||
|
rejectReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SupportTicketRemarkDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type {
|
||||||
|
CreateSupportTicketDto,
|
||||||
|
RejectSupportTicketDto,
|
||||||
|
SupportTicketListQueryDto,
|
||||||
|
SupportTicketRemarkDto,
|
||||||
|
} from './dto/support-ticket.dto';
|
||||||
|
|
||||||
|
function generateSupportTicketNo() {
|
||||||
|
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupportTicketService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateSupportTicketDto,
|
||||||
|
creator: { id: bigint; name: string },
|
||||||
|
) {
|
||||||
|
const ticket = await this.prisma.commonSupportTicket.create({
|
||||||
|
data: {
|
||||||
|
ticketNo: generateSupportTicketNo(),
|
||||||
|
ticketType: dto.ticketType as SupportTicketType,
|
||||||
|
status: 'PENDING_REVIEW',
|
||||||
|
title: dto.title.trim(),
|
||||||
|
content: dto.content?.trim() || null,
|
||||||
|
remark: dto.remark?.trim() || null,
|
||||||
|
creatorId: creator.id,
|
||||||
|
creatorName: creator.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: SupportTicketListQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const where: Prisma.CommonSupportTicketWhereInput = {};
|
||||||
|
if (query.ticketType) {
|
||||||
|
where.ticketType = query.ticketType as SupportTicketType;
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status as SupportTicketStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.commonSupportTicket.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.commonSupportTicket.count({ where }),
|
||||||
|
]);
|
||||||
|
return serializeBigInt({ items, total, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: bigint) {
|
||||||
|
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||||
|
return serializeBigInt(ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrThrow(id: bigint) {
|
||||||
|
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最高管理员评审通过 → 进入开发 */
|
||||||
|
async approve(
|
||||||
|
id: bigint,
|
||||||
|
reviewer: { id: bigint; name: string },
|
||||||
|
dto?: SupportTicketRemarkDto,
|
||||||
|
) {
|
||||||
|
const ticket = await this.getOrThrow(id);
|
||||||
|
if (ticket.status !== 'PENDING_REVIEW') {
|
||||||
|
throw new BadRequestException('仅待评审工单可通过评审');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.commonSupportTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'DEVELOPING',
|
||||||
|
reviewerId: reviewer.id,
|
||||||
|
reviewerName: reviewer.name,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
remark: dto?.remark?.trim() || ticket.remark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最高管理员评审驳回 */
|
||||||
|
async reject(
|
||||||
|
id: bigint,
|
||||||
|
reviewer: { id: bigint; name: string },
|
||||||
|
dto: RejectSupportTicketDto,
|
||||||
|
) {
|
||||||
|
const ticket = await this.getOrThrow(id);
|
||||||
|
if (ticket.status !== 'PENDING_REVIEW') {
|
||||||
|
throw new BadRequestException('仅待评审工单可驳回');
|
||||||
|
}
|
||||||
|
const reason = dto.rejectReason.trim();
|
||||||
|
if (!reason) throw new BadRequestException('请填写驳回理由');
|
||||||
|
|
||||||
|
const updated = await this.prisma.commonSupportTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectReason: reason,
|
||||||
|
reviewerId: reviewer.id,
|
||||||
|
reviewerName: reviewer.name,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开发完成 → 测试 */
|
||||||
|
async startTesting(id: bigint, dto?: SupportTicketRemarkDto) {
|
||||||
|
const ticket = await this.getOrThrow(id);
|
||||||
|
if (ticket.status !== 'DEVELOPING') {
|
||||||
|
throw new BadRequestException('仅开发中工单可转入测试');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.commonSupportTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'TESTING',
|
||||||
|
remark: dto?.remark?.trim() || ticket.remark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试通过 */
|
||||||
|
async pass(id: bigint, dto?: SupportTicketRemarkDto) {
|
||||||
|
const ticket = await this.getOrThrow(id);
|
||||||
|
if (ticket.status !== 'TESTING') {
|
||||||
|
throw new BadRequestException('仅测试中工单可标记通过');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.commonSupportTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'PASSED',
|
||||||
|
remark: dto?.remark?.trim() || ticket.remark,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
|
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { SupportTicketService } from '../common/support-ticket.service';
|
||||||
|
import {
|
||||||
|
CreateSupportTicketDto,
|
||||||
|
RejectSupportTicketDto,
|
||||||
|
SupportTicketListQueryDto,
|
||||||
|
SupportTicketRemarkDto,
|
||||||
|
} from '../common/dto/support-ticket.dto';
|
||||||
|
|
||||||
|
@Controller('admin/support-tickets')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminSupportTicketsController {
|
||||||
|
constructor(
|
||||||
|
private readonly service: SupportTicketService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async resolveHqAccount(user: AuthUser) {
|
||||||
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
|
where: { id: user.actorId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (!account) throw new NotFoundException('HQ 账户不存在');
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: SupportTicketListQueryDto) {
|
||||||
|
return this.service.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.SUPPORT_TICKET_CREATE,
|
||||||
|
refType: 'SUPPORT_TICKET',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
async create(@CurrentUser() user: AuthUser, @Body() body: CreateSupportTicketDto) {
|
||||||
|
const account = await this.resolveHqAccount(user);
|
||||||
|
return this.service.create(body, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.service.detail(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.SUPPORT_TICKET_APPROVE,
|
||||||
|
refType: 'SUPPORT_TICKET',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
async approve(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: SupportTicketRemarkDto,
|
||||||
|
) {
|
||||||
|
const account = await this.resolveHqAccount(user);
|
||||||
|
return this.service.approve(BigInt(id), account, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.SUPPORT_TICKET_REJECT,
|
||||||
|
refType: 'SUPPORT_TICKET',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
async reject(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: RejectSupportTicketDto,
|
||||||
|
) {
|
||||||
|
const account = await this.resolveHqAccount(user);
|
||||||
|
return this.service.reject(BigInt(id), account, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/start-testing')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.SUPPORT_TICKET_START_TESTING,
|
||||||
|
refType: 'SUPPORT_TICKET',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
startTesting(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||||
|
return this.service.startTesting(BigInt(id), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/pass')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.SUPPORT_TICKET_PASS,
|
||||||
|
refType: 'SUPPORT_TICKET',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
pass(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||||
|
return this.service.pass(BigInt(id), body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import { AdminOssLogsController } from './admin-oss-logs.controller';
|
|||||||
import { AdminOssLogsService } from './admin-oss-logs.service';
|
import { AdminOssLogsService } from './admin-oss-logs.service';
|
||||||
import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller';
|
import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller';
|
||||||
import { AdminTicketsService } from './admin-tickets.service';
|
import { AdminTicketsService } from './admin-tickets.service';
|
||||||
|
import { AdminSupportTicketsController } from './admin-support-tickets.controller';
|
||||||
import { AdminInvoicesController } from './admin-invoices.controller';
|
import { AdminInvoicesController } from './admin-invoices.controller';
|
||||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { BenefitModule } from '../benefit/benefit.module';
|
import { BenefitModule } from '../benefit/benefit.module';
|
||||||
@@ -88,6 +89,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
|||||||
AdminHqLogsController,
|
AdminHqLogsController,
|
||||||
AdminOssLogsController,
|
AdminOssLogsController,
|
||||||
AdminTicketsController,
|
AdminTicketsController,
|
||||||
|
AdminSupportTicketsController,
|
||||||
AdminInvoicesController,
|
AdminInvoicesController,
|
||||||
AdminXiaofeixiaController,
|
AdminXiaofeixiaController,
|
||||||
AdminProductDetailTemplatesController,
|
AdminProductDetailTemplatesController,
|
||||||
|
|||||||
Reference in New Issue
Block a user