From 959e0b66cb02abc66257565de42be39c81bbf2bf Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 26 Jul 2026 07:31:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=80=E6=9C=AF=E6=94=AF=E6=8C=81=E5=B7=A5?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 14 +- apps/admin-web/src/lib/hq-log.ts | 5 + .../src/pages/SupportTicketsPage.tsx | 384 ++++++++++++++++++ packages/shared-types/src/hq-permissions.ts | 13 +- packages/shared-types/src/index.ts | 1 + packages/shared-types/src/support-ticket.ts | 64 +++ server/dukang-api/prisma/init_v3.sql | 25 ++ server/dukang-api/prisma/schema.prisma | 39 ++ .../hq-operation/hq-operation.constants.ts | 10 + .../src/modules/common/common.module.ts | 12 +- .../modules/common/dto/support-ticket.dto.ts | 58 +++ .../modules/common/support-ticket.service.ts | 161 ++++++++ .../ops/admin-support-tickets.controller.ts | 120 ++++++ .../dukang-api/src/modules/ops/ops.module.ts | 2 + 15 files changed, 906 insertions(+), 4 deletions(-) create mode 100644 apps/admin-web/src/pages/SupportTicketsPage.tsx create mode 100644 packages/shared-types/src/support-ticket.ts create mode 100644 server/dukang-api/src/modules/common/dto/support-ticket.dto.ts create mode 100644 server/dukang-api/src/modules/common/support-ticket.service.ts create mode 100644 server/dukang-api/src/modules/ops/admin-support-tickets.controller.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index c40a53a..6d19e71 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -32,6 +32,7 @@ import StoreBillsPage from './pages/StoreBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import WineryBillsPage from './pages/WineryBillsPage'; import TicketsPage from './pages/TicketsPage'; +import SupportTicketsPage from './pages/SupportTicketsPage'; import InvoicesPage from './pages/InvoicesPage'; import UserLogsPage from './pages/UserLogsPage'; import HqLogsPage from './pages/HqLogsPage'; @@ -92,6 +93,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index b11b7d0..701e89d 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -94,7 +94,15 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, ], }, - { key: '/tickets', icon: , label: '工单中心' }, + { + key: 'tickets-group', + icon: , + label: '工单', + children: [ + { key: '/tickets', label: '工单中心' }, + { key: '/tickets/support', label: '技术支持' }, + ], + }, { key: '/invoices', icon: , label: '发票管理' }, { key: '/resources', icon: , label: 'OSS 资源库' }, { @@ -115,6 +123,9 @@ const MENU_ITEMS: MenuProps['items'] = [ ]; function menuAllowed(key: string, permissionKeys: string[]): boolean { + if (key === 'tickets-group') { + return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support'); + } const map: Record = { '/': 'dashboard', '/users': 'users', @@ -148,6 +159,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/deliveries': 'deliveries', '/deliveries/xiaofeixia': 'deliveries', '/tickets': 'tickets', + '/tickets/support': 'tech_support', '/invoices': 'invoices', '/resources': 'resources', 'logs-group': 'logs', diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index d9e7bf7..760c75e 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -39,6 +39,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'TICKET_APPROVE', label: '工单通过' }, { value: 'TICKET_REJECT', 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_ISSUE', label: '开具发票' }, { value: 'INVOICE_REJECT', label: '驳回发票' }, diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx new file mode 100644 index 0000000..663db62 --- /dev/null +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -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 = { + 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(null); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = + useAdminList('/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(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('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); + + async function openDetail(id: string) { + setDetail(await request(`/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 = [ + { 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) => ( + {SUPPORT_TICKET_STATUS_LABELS[s] ?? s} + ), + }, + { title: '标题', dataIndex: 'title', ellipsis: true }, + { title: '创建人', dataIndex: 'creatorName', width: 100 }, + { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + + const drawerExtra = (() => { + if (!detail) return null; + if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) { + return ( + + + + + ); + } + if (detail.status === 'DEVELOPING') { + return ( + + ); + } + if (detail.status === 'TESTING') { + return ( + + ); + } + return null; + })(); + + return ( +
+
+ + 技术支持 + + +
+ +
{ + setFilters(v); + setPage(1); + }} + > + + + + +
+ + { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + extra={drawerExtra} + > + {detail && ( + + {detail.ticketNo} + + {SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} + + + + {SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status} + + + {detail.title} + +
{detail.content || '—'}
+
+ {detail.creatorName} + {fmtTime(detail.createdAt)} + {detail.reviewerName || '—'} + + {detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'} + + {detail.rejectReason || '—'} + {detail.remark || '—'} +
+ )} +
+ + setCreateOpen(false)} + onOk={() => void submitCreate()} + confirmLoading={creating} + destroyOnClose + okText="提交" + > +
+ + + + + + + + + + +
+ + setRejectOpen(false)} + onOk={() => void submitReject()} + confirmLoading={acting} + destroyOnClose + okText="确认驳回" + okButtonProps={{ danger: true }} + > +
+ + + + +
+ + ); +} diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index 774cd6f..8ac023b 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -12,6 +12,7 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'benefit', label: '好客权益', group: '业务' }, { key: 'deliveries', label: '配送单', group: '业务' }, { key: 'tickets', label: '工单中心', group: '业务' }, + { key: 'tech_support', label: '技术支持', group: '业务' }, { key: 'invoices', label: '发票管理', group: '业务' }, { key: 'resources', label: 'OSS 资源库', group: '业务' }, { key: 'logs', label: '日志', group: '业务' }, @@ -85,6 +86,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { 'benefit', 'deliveries', 'tickets', + 'tech_support', 'invoices', 'resources', 'logs', @@ -97,9 +99,18 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { 'partners', 'finance', 'benefit', + 'tech_support', 'invoices', 'logs', 'system_settings_winery_bank', ], - CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'], + CUSTOMER_SERVICE: [ + 'dashboard', + 'users', + 'orders', + 'tickets', + 'tech_support', + 'invoices', + 'logs', + ], }; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 612c1e1..4cb37c1 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -9,6 +9,7 @@ export * from './redeem'; export * from './settlement'; export * from './ops'; export * from './ticket'; +export * from './support-ticket'; export * from './invoice'; export * from './user-log'; diff --git a/packages/shared-types/src/support-ticket.ts b/packages/shared-types/src/support-ticket.ts new file mode 100644 index 0000000..75aea03 --- /dev/null +++ b/packages/shared-types/src/support-ticket.ts @@ -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 = { + 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 = { + 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; +} diff --git a/server/dukang-api/prisma/init_v3.sql b/server/dukang-api/prisma/init_v3.sql index efd356b..a6bb7c4 100644 --- a/server/dukang-api/prisma/init_v3.sql +++ b/server/dukang-api/prisma/init_v3.sql @@ -101,6 +101,31 @@ CREATE TABLE common_ticket ( KEY idx_common_ticket_type_status (ticket_type, status) ) 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; CREATE TABLE common_product_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index fe0f0f6..0af7265 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -88,6 +88,20 @@ enum TicketType { RETURN_REFUND } +enum SupportTicketType { + BUG + SUGGESTION + OTHER +} + +enum SupportTicketStatus { + PENDING_REVIEW + REJECTED + DEVELOPING + TESTING + PASSED +} + enum InvoiceTitleType { PERSONAL ENTERPRISE @@ -440,6 +454,31 @@ model CommonTicket { @@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 { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt skuCode String @unique @map("sku_code") @db.VarChar(32) diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 225320b..99f4672 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -46,6 +46,11 @@ export const HqOperationAction = { TICKET_APPROVE: 'TICKET_APPROVE', TICKET_REJECT: 'TICKET_REJECT', 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_ISSUE: 'INVOICE_ISSUE', INVOICE_REJECT: 'INVOICE_REJECT', @@ -124,6 +129,11 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.TICKET_APPROVE]: '工单通过', [HqOperationAction.TICKET_REJECT]: '工单驳回', [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_ISSUE]: '开具发票', [HqOperationAction.INVOICE_REJECT]: '驳回发票', diff --git a/server/dukang-api/src/modules/common/common.module.ts b/server/dukang-api/src/modules/common/common.module.ts index d87c5cc..e6b423f 100644 --- a/server/dukang-api/src/modules/common/common.module.ts +++ b/server/dukang-api/src/modules/common/common.module.ts @@ -6,6 +6,7 @@ import { SystemConfigModule } from '../../common/system-config/system-config.mod import { ResourceService } from './resource.service'; import { EventService } from './event.service'; import { TicketService } from './ticket.service'; +import { SupportTicketService } from './support-ticket.service'; import { ThirdPartyLogService } from './third-party-log.service'; import { ResourceController } from './resource.controller'; import { EventController } from './event.controller'; @@ -25,7 +26,14 @@ import { WechatLocationService } from './wechat-location.service'; WechatController, ClientConfigController, ], - providers: [ResourceService, EventService, TicketService, ThirdPartyLogService, WechatLocationService], - exports: [ResourceService, EventService, TicketService], + providers: [ + ResourceService, + EventService, + TicketService, + SupportTicketService, + ThirdPartyLogService, + WechatLocationService, + ], + exports: [ResourceService, EventService, TicketService, SupportTicketService], }) export class CommonModule {} diff --git a/server/dukang-api/src/modules/common/dto/support-ticket.dto.ts b/server/dukang-api/src/modules/common/dto/support-ticket.dto.ts new file mode 100644 index 0000000..45b5d0c --- /dev/null +++ b/server/dukang-api/src/modules/common/dto/support-ticket.dto.ts @@ -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; +} diff --git a/server/dukang-api/src/modules/common/support-ticket.service.ts b/server/dukang-api/src/modules/common/support-ticket.service.ts new file mode 100644 index 0000000..6d369d2 --- /dev/null +++ b/server/dukang-api/src/modules/common/support-ticket.service.ts @@ -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); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-support-tickets.controller.ts b/server/dukang-api/src/modules/ops/admin-support-tickets.controller.ts new file mode 100644 index 0000000..e0b73c9 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-support-tickets.controller.ts @@ -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); + } +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 84fe912..952c011 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -38,6 +38,7 @@ import { AdminOssLogsController } from './admin-oss-logs.controller'; import { AdminOssLogsService } from './admin-oss-logs.service'; import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller'; import { AdminTicketsService } from './admin-tickets.service'; +import { AdminSupportTicketsController } from './admin-support-tickets.controller'; import { AdminInvoicesController } from './admin-invoices.controller'; import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import { BenefitModule } from '../benefit/benefit.module'; @@ -88,6 +89,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide AdminHqLogsController, AdminOssLogsController, AdminTicketsController, + AdminSupportTicketsController, AdminInvoicesController, AdminXiaofeixiaController, AdminProductDetailTemplatesController,