From 09fe6d73f812a6445c53b240c42ba13b7d568123 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期五, 31 七月 2026 09:58:12 +0800
Subject: [PATCH] 银川 1.售后管理模块开发联调
---
src/views/aftersales/ticket/data.ts | 489 +++++++++
src/api/aftersales/return.ts | 78 +
src/views/aftersales/ticket/components/select-dialog.vue | 163 +++
src/packages/constants/src/biz-aftersales-enum.ts | 45
src/router/routes/modules/aftersales.ts | 36
src/views/aftersales/ticket/index.vue | 212 ++++
src/views/aftersales/ticket/components/select.vue | 111 ++
src/views/srm/tender/detail.vue | 5
src/views/aftersales/return/modules/form.vue | 142 ++
src/views/aftersales/ticket/modules/form.vue | 318 ++++++
src/views/aftersales/return/data.ts | 318 ++++++
src/packages/constants/src/index.ts | 1
src/views/aftersales/return/detail.vue | 356 +++++++
src/api/aftersales/ticket.ts | 128 ++
src/packages/constants/src/dict-enum.ts | 10
src/views/aftersales/ticket/components/index.ts | 2
src/views/aftersales/ticket/detail.vue | 331 ++++++
src/views/aftersales/return/index.vue | 160 +++
18 files changed, 2,903 insertions(+), 2 deletions(-)
diff --git a/src/api/aftersales/return.ts b/src/api/aftersales/return.ts
new file mode 100644
index 0000000..932865d
--- /dev/null
+++ b/src/api/aftersales/return.ts
@@ -0,0 +1,78 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace AftersalesReturnApi {
+ /** 閫�璐ф槑缁嗚 */
+ export interface ReturnItem {
+ id?: number;
+ returnId?: number;
+ ticketItemId?: number;
+ itemId?: number;
+ itemCode?: string;
+ itemName?: string;
+ batchId?: number;
+ batchCode?: string;
+ returnQuantity?: number;
+ unitPrice?: number;
+ returnPrice?: number;
+ remark?: string;
+ }
+
+ /** 鍞悗閫�璐х敵璇� */
+ export interface Return {
+ id?: number;
+ no?: string;
+ ticketId?: number;
+ customerId?: number;
+ customerName?: string;
+ saleOrderId?: number;
+ returnType?: number;
+ returnReason?: string;
+ defectCategory?: string;
+ totalReturnPrice?: number;
+ actualRefundPrice?: number;
+ status?: number;
+ auditUserId?: number;
+ auditTime?: string;
+ mesReturnId?: number;
+ mesReturnCode?: string;
+ qualityResult?: string;
+ qualityReportUrl?: string;
+ returnInboundTime?: string;
+ refundTime?: string;
+ remark?: string;
+ creator?: string;
+ createTime?: string;
+ updater?: string;
+ updateTime?: string;
+ items?: ReturnItem[];
+ }
+}
+
+/** 鍒涘缓閫�璐х敵璇� */
+export function createReturn(data: AftersalesReturnApi.Return) {
+ return requestClient.post<number>('/aftersales/return/create', data);
+}
+
+/** 瀹℃牳閫氳繃閫�璐х敵璇� */
+export function approveReturn(id: number) {
+ return requestClient.put<boolean>('/aftersales/return/approve', null, {
+ params: { id },
+ });
+}
+
+/** 鑾峰彇閫�璐х敵璇疯鎯� */
+export function getReturn(id: number) {
+ return requestClient.get<AftersalesReturnApi.Return>(
+ `/aftersales/return/get?id=${id}`,
+ );
+}
+
+/** 鍒嗛〉鏌ヨ閫�璐х敵璇� */
+export function getReturnPage(params: PageParam) {
+ return requestClient.get<PageResult<AftersalesReturnApi.Return>>(
+ '/aftersales/return/page',
+ { params },
+ );
+}
diff --git a/src/api/aftersales/ticket.ts b/src/api/aftersales/ticket.ts
new file mode 100644
index 0000000..be94b90
--- /dev/null
+++ b/src/api/aftersales/ticket.ts
@@ -0,0 +1,128 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace AftersalesTicketApi {
+ /** 宸ュ崟鏄庣粏琛� */
+ export interface TicketItem {
+ id?: number;
+ ticketId?: number;
+ saleOrderItemId?: number;
+ itemId?: number;
+ itemCode?: string;
+ itemName?: string;
+ batchId?: number;
+ batchCode?: string;
+ saleQuantity?: number;
+ returnQuantity?: number;
+ needRepair?: boolean;
+ needReturn?: boolean;
+ remark?: string;
+ }
+
+ /** 鍞悗宸ュ崟 */
+ export interface Ticket {
+ id?: number;
+ no?: string;
+ name: string;
+ customerId?: number;
+ customerName?: string;
+ contactId?: number;
+ contactName?: string;
+ saleOrderId?: number;
+ saleOrderNo?: string;
+ contractId?: number;
+ issueType?: number;
+ issueCategory?: string;
+ issueDescription?: string;
+ severityLevel?: number;
+ status?: number;
+ ownerUserId?: number;
+ ownerUserName?: string;
+ handlerUserId?: number;
+ handlerUserName?: string;
+ processInstanceId?: string;
+ returnId?: number;
+ repairId?: number;
+ resolvedTime?: string;
+ closedTime?: string;
+ remark?: string;
+ creator?: string;
+ createTime?: string;
+ updater?: string;
+ updateTime?: string;
+ items?: TicketItem[];
+ }
+}
+
+/** 鍒涘缓鍞悗宸ュ崟 */
+export function createTicket(data: AftersalesTicketApi.Ticket) {
+ return requestClient.post<number>('/aftersales/ticket/create', data);
+}
+
+/** 鏇存柊鍞悗宸ュ崟 */
+export function updateTicket(data: AftersalesTicketApi.Ticket) {
+ return requestClient.put<boolean>('/aftersales/ticket/update', data);
+}
+
+/** 鍒犻櫎鍞悗宸ュ崟 */
+export function deleteTicket(id: number) {
+ return requestClient.delete<boolean>('/aftersales/ticket/delete', {
+ params: { id },
+ });
+}
+
+/** 鑾峰彇鍞悗宸ュ崟璇︽儏 */
+export function getTicket(id: number) {
+ return requestClient.get<AftersalesTicketApi.Ticket>(
+ `/aftersales/ticket/get?id=${id}`,
+ );
+}
+
+/** 鍒嗛〉鏌ヨ鍞悗宸ュ崟 */
+export function getTicketPage(params: PageParam) {
+ return requestClient.get<PageResult<AftersalesTicketApi.Ticket>>(
+ '/aftersales/ticket/page',
+ { params },
+ );
+}
+
+/** 鍒嗛厤澶勭悊浜� */
+export function assignTicket(id: number, handlerUserId: number) {
+ return requestClient.post<boolean>('/aftersales/ticket/assign', null, {
+ params: { id, handlerUserId },
+ });
+}
+
+/** 闂鍒ゅ畾 */
+export function classifyTicket(params: {
+ id: number;
+ issueType: number;
+ issueCategory?: string;
+ severityLevel?: number;
+}) {
+ return requestClient.post<boolean>('/aftersales/ticket/classify', null, {
+ params,
+ });
+}
+
+/** 瀹岀粨宸ュ崟 */
+export function resolveTicket(id: number) {
+ return requestClient.put<boolean>('/aftersales/ticket/resolve', null, {
+ params: { id },
+ });
+}
+
+/** 鍏抽棴宸ュ崟 */
+export function closeTicket(id: number) {
+ return requestClient.put<boolean>('/aftersales/ticket/close', null, {
+ params: { id },
+ });
+}
+
+/** 鍙栨秷宸ュ崟 */
+export function cancelTicket(id: number) {
+ return requestClient.put<boolean>('/aftersales/ticket/cancel', null, {
+ params: { id },
+ });
+}
diff --git a/src/packages/constants/src/biz-aftersales-enum.ts b/src/packages/constants/src/biz-aftersales-enum.ts
new file mode 100644
index 0000000..67ee213
--- /dev/null
+++ b/src/packages/constants/src/biz-aftersales-enum.ts
@@ -0,0 +1,45 @@
+/** 鍞悗宸ュ崟鐘舵�佹灇涓� */
+export const AftersaleTicketStatusEnum = {
+ DRAFT: 0, // 鑽夌
+ PROCESSING: 10, // 澶勭悊涓�
+ PENDING_RETURN: 30, // 寰呴��璐�
+ PENDING_REFUND: 40, // 寰呴��娆�/鎹㈣揣
+ RESOLVED: 50, // 宸插畬缁�
+ CLOSED: 60, // 宸插叧闂�
+ CANCELLED: 99, // 宸插彇娑�
+} as const;
+
+/** 鍞悗闂绫诲瀷鏋氫妇 */
+export const AftersaleIssueTypeEnum = {
+ GENERAL: 1, // 涓�鑸棶棰�
+ REPAIR: 2, // 缁翠慨闂
+ RETURN: 3, // 閫�璐ч棶棰�
+} as const;
+
+/** 鍞悗閫�璐х姸鎬佹灇涓� */
+export const AftersaleReturnStatusEnum = {
+ DRAFT: 0, // 鑽夌
+ AUDITING: 10, // 閿�鍞鏍镐腑
+ SYNCED_MES: 20, // 瀹℃牳閫氳繃-宸插悓姝ES
+ QC_CHECKING: 30, // 妫�楠屼腑
+ QC_DONE: 40, // 妫�楠屽畬鎴�
+ INBOUND: 50, // 宸插叆搴�
+ REFUND_DONE: 60, // 閫�娆�/鎹㈣揣瀹屾垚
+ CLOSED: 70, // 宸插叧闂�
+ CANCELLED: 99, // 宸插彇娑�
+} as const;
+
+/** 鍞悗閫�璐х被鍨嬫灇涓� */
+export const AftersaleReturnTypeEnum = {
+ REFUND_RETURN: 1, // 閫�娆鹃��璐�
+ EXCHANGE: 2, // 鎹㈣揣
+ REFUND_ONLY: 3, // 浠呴��娆�
+} as const;
+
+/** 鍞悗涓ラ噸绋嬪害鏋氫妇 */
+export const AftersaleSeverityLevelEnum = {
+ MINOR: 0, // 杞诲井
+ NORMAL: 1, // 涓�鑸�
+ SEVERE: 2, // 涓ラ噸
+ CRITICAL: 3, // 鑷村懡
+} as const;
diff --git a/src/packages/constants/src/dict-enum.ts b/src/packages/constants/src/dict-enum.ts
index ea20442..e899ad2 100644
--- a/src/packages/constants/src/dict-enum.ts
+++ b/src/packages/constants/src/dict-enum.ts
@@ -125,6 +125,15 @@
CRM_RECEIVABLE_RETURN_TYPE: 'crm_receivable_return_type', // CRM 鍥炴鐨勮繕娆炬柟寮�
} as const;
+/** ========== AFTERSALES - 鍞悗鏈嶅姟妯″潡 ========== */
+const AFTERSALES_DICT = {
+ AFTERSALE_TICKET_STATUS: 'aftersale_ticket_status', // 鍞悗宸ュ崟鐘舵��
+ AFTERSALE_RETURN_STATUS: 'aftersale_return_status', // 鍞悗閫�璐х姸鎬�
+ AFTERSALE_ISSUE_TYPE: 'aftersale_issue_type', // 鍞悗闂绫诲瀷
+ AFTERSALE_RETURN_TYPE: 'aftersale_return_type', // 鍞悗閫�璐х被鍨�
+ AFTERSALE_SEVERITY_LEVEL: 'aftersale_severity_level', // 鍞悗涓ラ噸绋嬪害
+} as const;
+
/** ========== ERP - 浼佷笟璧勬簮璁″垝妯″潡 ========== */
const ERP_DICT = {
ERP_AUDIT_STATUS: 'erp_audit_status', // ERP 瀹℃壒鐘舵��
@@ -313,6 +322,7 @@
/** 瀛楀吀绫诲瀷鏋氫妇 - 缁熶竴瀵煎嚭 */
const DICT_TYPE = {
+ ...AFTERSALES_DICT,
...AI_DICT,
...BPM_DICT,
...CRM_DICT,
diff --git a/src/packages/constants/src/index.ts b/src/packages/constants/src/index.ts
index 27087b6..412ec82 100644
--- a/src/packages/constants/src/index.ts
+++ b/src/packages/constants/src/index.ts
@@ -1,3 +1,4 @@
+export * from './biz-aftersales-enum';
export * from './biz-ai-enum';
export * from './biz-bpm-enum';
export * from './biz-erp-enum';
diff --git a/src/router/routes/modules/aftersales.ts b/src/router/routes/modules/aftersales.ts
new file mode 100644
index 0000000..288f840
--- /dev/null
+++ b/src/router/routes/modules/aftersales.ts
@@ -0,0 +1,36 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+const routes: RouteRecordRaw[] = [
+ {
+ path: '/aftersales',
+ name: 'AftersalesCenter',
+ meta: {
+ title: '鍞悗鏈嶅姟',
+ icon: 'lucide:headset',
+ keepAlive: true,
+ hideInMenu: true,
+ },
+ children: [
+ {
+ path: 'ticket/detail/:id',
+ name: 'AftersalesTicketDetail',
+ meta: {
+ title: '鍞悗宸ュ崟璇︽儏',
+ activePath: '/aftersales/ticket',
+ },
+ component: () => import('#/views/aftersales/ticket/detail.vue'),
+ },
+ {
+ path: 'return/detail/:id',
+ name: 'AftersalesReturnDetail',
+ meta: {
+ title: '閫�璐х敵璇疯鎯�',
+ activePath: '/aftersales/return',
+ },
+ component: () => import('#/views/aftersales/return/detail.vue'),
+ },
+ ],
+ },
+];
+
+export default routes;
diff --git a/src/views/aftersales/return/data.ts b/src/views/aftersales/return/data.ts
new file mode 100644
index 0000000..5acd874
--- /dev/null
+++ b/src/views/aftersales/return/data.ts
@@ -0,0 +1,318 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { AftersalesReturnApi } from '#/api/aftersales/return';
+
+import { DICT_TYPE } from '@vben/constants';
+import { getDictOptions } from '@vben/hooks';
+
+export { AftersaleReturnStatusEnum } from '@vben/constants';
+
+/** 閫�璐х被鍨嬮�夐」 */
+export const RETURN_TYPE_OPTIONS = [
+ { label: '閫�娆鹃��璐�', value: 1 },
+ { label: '鎹㈣揣', value: 2 },
+ { label: '浠呴��娆�', value: 3 },
+];
+
+/** 琛ㄥ崟绫诲瀷 */
+export type ReturnFormType = 'create' | 'detail';
+
+/** 閫�璐х敵璇疯〃鍗� schema */
+export function useReturnFormSchema(
+ formType: ReturnFormType,
+): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'ticketId',
+ label: '鍞悗宸ュ崟',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '鍞悗宸ュ崟',
+ },
+ },
+ {
+ fieldName: 'customerName',
+ label: '瀹㈡埛',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '瀹㈡埛',
+ },
+ },
+ {
+ fieldName: 'saleOrderId',
+ label: '閿�鍞鍗旾D',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ ...(formType === 'detail'
+ ? [
+ {
+ fieldName: 'no',
+ label: '閫�璐у崟鍙�',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '閫�璐у崟鍙�',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '鍗曟嵁鐘舵��',
+ component: 'Select',
+ componentProps: {
+ disabled: true,
+ options: getDictOptions(
+ DICT_TYPE.AFTERSALE_RETURN_STATUS,
+ 'number',
+ ),
+ placeholder: '鍗曟嵁鐘舵��',
+ },
+ },
+ {
+ fieldName: 'totalReturnPrice',
+ label: '搴旈��閲戦',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '搴旈��閲戦',
+ },
+ },
+ {
+ fieldName: 'actualRefundPrice',
+ label: '瀹為��閲戦',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '瀹為��閲戦',
+ },
+ },
+ {
+ fieldName: 'mesReturnCode',
+ label: 'MES閫�璐у崟鍙�',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: 'MES閫�璐у崟鍙�',
+ },
+ },
+ {
+ fieldName: 'qualityResult',
+ label: '璐ㄦ缁撴灉',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '璐ㄦ缁撴灉',
+ },
+ },
+ {
+ fieldName: 'qualityReportUrl',
+ label: '璐ㄦ鎶ュ憡',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '璐ㄦ鎶ュ憡',
+ },
+ },
+ ]
+ : []),
+ {
+ fieldName: 'returnType',
+ label: '閫�璐х被鍨�',
+ component: 'Select',
+ componentProps: {
+ options: RETURN_TYPE_OPTIONS,
+ placeholder: '璇烽�夋嫨閫�璐х被鍨�',
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'returnReason',
+ label: '閫�璐у師鍥�',
+ component: 'Textarea',
+ formItemClass: 'col-span-3',
+ componentProps: {
+ placeholder: '璇疯緭鍏ラ��璐у師鍥�',
+ rows: 3,
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'defectCategory',
+ label: '缂洪櫡绫诲埆',
+ component: 'Input',
+ componentProps: {
+ placeholder: '璇疯緭鍏ョ己闄风被鍒�',
+ },
+ },
+ {
+ fieldName: 'remark',
+ label: '澶囨敞',
+ component: 'Textarea',
+ formItemClass: 'col-span-3',
+ componentProps: {
+ placeholder: '璇疯緭鍏ュ娉�',
+ rows: 3,
+ },
+ },
+ ];
+}
+
+/** 鍒楄〃鎼滅储琛ㄥ崟 schema */
+export function useReturnGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'no',
+ label: '閫�璐у崟鍙�',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ラ��璐у崟鍙�',
+ },
+ },
+ {
+ fieldName: 'ticketId',
+ label: '鍞悗宸ュ崟',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ュ敭鍚庡伐鍗曞彿',
+ },
+ },
+ {
+ fieldName: 'customerId',
+ label: '瀹㈡埛',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ュ鎴�',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '鍗曟嵁鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.AFTERSALE_RETURN_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨鍗曟嵁鐘舵��',
+ },
+ },
+ ];
+}
+
+/** 鍒楄〃瀛楁 */
+export function useReturnGridColumns(): VxeTableGridOptions<AftersalesReturnApi.Return>['columns'] {
+ return [
+ {
+ field: 'no',
+ title: '閫�璐у崟鍙�',
+ minWidth: 160,
+ slots: { default: 'no' },
+ },
+ {
+ field: 'ticketId',
+ title: '鍞悗宸ュ崟',
+ minWidth: 120,
+ slots: { default: 'ticketId' },
+ },
+ {
+ field: 'customerName',
+ title: '瀹㈡埛鍚嶇О',
+ minWidth: 150,
+ },
+ {
+ field: 'returnType',
+ title: '閫�璐х被鍨�',
+ minWidth: 110,
+ slots: { default: 'returnType' },
+ },
+ {
+ field: 'totalReturnPrice',
+ title: '搴旈��閲戦',
+ minWidth: 110,
+ },
+ {
+ field: 'actualRefundPrice',
+ title: '瀹為��閲戦',
+ minWidth: 110,
+ },
+ {
+ field: 'status',
+ title: '鍗曟嵁鐘舵��',
+ minWidth: 110,
+ slots: { default: 'status' },
+ },
+ {
+ field: 'mesReturnCode',
+ title: 'MES閫�璐у崟鍙�',
+ minWidth: 140,
+ },
+ {
+ field: 'qualityResult',
+ title: '璐ㄦ缁撴灉',
+ minWidth: 110,
+ slots: { default: 'qualityResult' },
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ width: 180,
+ formatter: 'formatDate',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 180,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 閫�璐ф槑缁嗗瓙琛ㄥ瓧娈� */
+export function useReturnItemGridColumns(): VxeTableGridOptions<AftersalesReturnApi.ReturnItem>['columns'] {
+ return [
+ {
+ field: 'itemCode',
+ title: '鐗╂枡缂栫爜',
+ minWidth: 120,
+ },
+ {
+ field: 'itemName',
+ title: '鐗╂枡鍚嶇О',
+ minWidth: 140,
+ },
+ {
+ field: 'batchCode',
+ title: '鎵规鍙�',
+ minWidth: 120,
+ },
+ {
+ field: 'returnQuantity',
+ title: '閫�璐ф暟閲�',
+ width: 100,
+ },
+ {
+ field: 'unitPrice',
+ title: '鍗曚环',
+ width: 100,
+ },
+ {
+ field: 'returnPrice',
+ title: '閫�璐ч噾棰�',
+ width: 110,
+ },
+ ];
+}
diff --git a/src/views/aftersales/return/detail.vue b/src/views/aftersales/return/detail.vue
new file mode 100644
index 0000000..2254f0f
--- /dev/null
+++ b/src/views/aftersales/return/detail.vue
@@ -0,0 +1,356 @@
+<script lang="ts" setup>
+import type { AftersalesReturnApi } from '#/api/aftersales/return';
+import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
+import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
+
+import { computed, onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { Page } from '@vben/common-ui';
+import { AftersaleReturnStatusEnum } from '@vben/constants';
+
+import { Button, Card, Descriptions, message, Popconfirm, Select, Tag } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { approveReturn, createReturn, getReturn } from '#/api/aftersales/return';
+import { getTicket } from '#/api/aftersales/ticket';
+import { AftersalesTicketSelect } from '#/views/aftersales/ticket/components';
+import { getSaleOrder } from '#/api/erp/sale/order';
+
+defineOptions({ name: 'AftersalesReturnDetail' });
+
+const route = useRoute();
+const router = useRouter();
+const loading = ref(false);
+const submitting = ref(false);
+const returnData = ref<AftersalesReturnApi.Return>();
+const ticketData = ref<AftersalesTicketApi.Ticket>();
+const saleOrderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]);
+
+const returnId = computed(() => route.params.id as string);
+const ticketIdFromQuery = computed(() => Number(route.query.ticketId || 0));
+const isCreate = computed(() => returnId.value === 'new');
+
+const isDraft = computed(() => returnData.value?.status === AftersaleReturnStatusEnum.DRAFT);
+
+const statusMap: Record<number, { color: string; label: string }> = {
+ [AftersaleReturnStatusEnum.DRAFT]: { color: 'default', label: '鑽夌' },
+ [AftersaleReturnStatusEnum.AUDITING]: { color: 'processing', label: '閿�鍞鏍镐腑' },
+ [AftersaleReturnStatusEnum.SYNCED_MES]: { color: 'processing', label: '宸插悓姝ES' },
+ [AftersaleReturnStatusEnum.QC_CHECKING]: { color: 'warning', label: '妫�楠屼腑' },
+ [AftersaleReturnStatusEnum.QC_DONE]: { color: 'warning', label: '妫�楠屽畬鎴�' },
+ [AftersaleReturnStatusEnum.INBOUND]: { color: 'success', label: '宸插叆搴�' },
+ [AftersaleReturnStatusEnum.REFUND_DONE]: { color: 'success', label: '閫�娆�/鎹㈣揣瀹屾垚' },
+ [AftersaleReturnStatusEnum.CLOSED]: { color: 'default', label: '宸插叧闂�' },
+ [AftersaleReturnStatusEnum.CANCELLED]: { color: 'error', label: '宸插彇娑�' },
+};
+
+const returnTypeMap: Record<number, string> = {
+ 1: '閫�娆鹃��璐�',
+ 2: '鎹㈣揣',
+ 3: '浠呴��娆�',
+};
+
+const returnTypeOptions = [
+ { label: '閫�娆鹃��璐�', value: 1 },
+ { label: '鎹㈣揣', value: 2 },
+ { label: '浠呴��娆�', value: 3 },
+];
+
+const [CreateForm, createFormApi] = useVbenForm({
+ commonConfig: {
+ componentProps: { class: 'w-full' },
+ formItemClass: 'col-span-1',
+ labelWidth: 100,
+ },
+ layout: 'horizontal',
+ schema: [
+ {
+ fieldName: 'returnType',
+ label: '閫�璐х被鍨�',
+ component: 'Select',
+ componentProps: {
+ options: returnTypeOptions,
+ placeholder: '璇烽�夋嫨閫�璐х被鍨�',
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'returnReason',
+ label: '閫�璐у師鍥�',
+ component: 'Textarea',
+ formItemClass: 'col-span-3',
+ componentProps: {
+ placeholder: '璇疯緭鍏ラ��璐у師鍥�',
+ rows: 3,
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'defectCategory',
+ label: '缂洪櫡绫诲埆',
+ component: 'Input',
+ componentProps: { placeholder: '璇疯緭鍏ョ己闄风被鍒�' },
+ },
+ {
+ fieldName: 'remark',
+ label: '澶囨敞',
+ component: 'Textarea',
+ formItemClass: 'col-span-3',
+ componentProps: {
+ placeholder: '璇疯緭鍏ュ娉�',
+ rows: 3,
+ },
+ },
+ ],
+ showDefaultActions: false,
+ wrapperClass: 'grid-cols-3',
+});
+
+async function loadReturn() {
+ loading.value = true;
+ try {
+ returnData.value = await getReturn(Number(returnId.value));
+ } finally {
+ loading.value = false;
+ }
+}
+
+async function loadTicketForCreate() {
+ if (!ticketIdFromQuery.value) return;
+ loading.value = true;
+ try {
+ ticketData.value = await getTicket(ticketIdFromQuery.value);
+ if (ticketData.value?.saleOrderId) {
+ const order = await getSaleOrder(ticketData.value.saleOrderId);
+ saleOrderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || [];
+ }
+ } finally {
+ loading.value = false;
+ }
+}
+
+async function handleTicketChange(item: Record<string, unknown> | undefined) {
+ if (!item?.id) { ticketData.value = undefined; return; }
+ loading.value = true;
+ try {
+ ticketData.value = await getTicket(item.id as number);
+ if (ticketData.value?.saleOrderId) {
+ const order = await getSaleOrder(ticketData.value.saleOrderId);
+ saleOrderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || [];
+ }
+ } finally {
+ loading.value = false;
+ }
+}
+
+const needReturnItems = computed(() =>
+ (ticketData.value?.items || []).filter((item) => item.needReturn),
+);
+
+async function handleCreateReturn() {
+ const { valid } = await createFormApi.validate();
+ if (!valid) return;
+
+ submitting.value = true;
+ try {
+ const formValues = await createFormApi.getValues();
+ const newId = await createReturn({
+ ticketId: ticketData.value!.id!,
+ customerId: ticketData.value!.customerId!,
+ saleOrderId: ticketData.value!.saleOrderId,
+ returnType: formValues.returnType as number,
+ returnReason: formValues.returnReason as string,
+ defectCategory: formValues.defectCategory as string | undefined,
+ remark: formValues.remark as string | undefined,
+ items: needReturnItems.value.map((item) => {
+ const orderItem = saleOrderItems.value.find((oi) => oi.productId === item.itemId);
+ const unitPrice = orderItem?.productPrice || 0;
+ const returnQuantity = item.returnQuantity || 0;
+ return {
+ ticketItemId: item.id,
+ itemId: item.itemId,
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ batchId: item.batchId,
+ batchCode: item.batchCode,
+ returnQuantity,
+ unitPrice,
+ returnPrice: Number((unitPrice * returnQuantity).toFixed(2)),
+ };
+ }),
+ });
+ message.success('閫�璐х敵璇峰垱寤烘垚鍔�');
+ router.replace({ name: 'AftersalesReturnDetail', params: { id: newId } });
+ } catch {
+ // 鍒涘缓澶辫触锛岀暀鍦ㄥ綋鍓嶉〉
+ } finally {
+ submitting.value = false;
+ }
+}
+
+async function handleApprove() {
+ await approveReturn(returnData.value!.id!);
+ message.success('瀹℃牳閫氳繃锛屽凡鍚屾MES鍒涘缓閫�璐у崟');
+ await loadReturn();
+}
+
+function goBack() {
+ router.back();
+}
+
+onMounted(async () => {
+ if (isCreate.value) {
+ await loadTicketForCreate();
+ } else {
+ await loadReturn();
+ }
+});
+</script>
+
+<template>
+ <Page auto-content-height :loading="loading">
+ <template #title>
+ <div class="flex items-center gap-3">
+ <Button @click="goBack">杩斿洖</Button>
+ <span class="text-lg font-bold">
+ {{ isCreate ? '鍒涘缓閫�璐х敵璇�' : '鍞悗閫�璐х敵璇疯鎯�' }}
+ </span>
+ </div>
+ </template>
+
+ <!-- 鍒涘缓妯″紡 -->
+ <template v-if="isCreate && ticketData">
+ <Card title="宸ュ崟淇℃伅" class="mb-4">
+ <Descriptions :column="3" bordered size="small">
+ <Descriptions.Item label="宸ュ崟鍗曞彿">{{ ticketData.no }}</Descriptions.Item>
+ <Descriptions.Item label="宸ュ崟涓婚">{{ ticketData.name }}</Descriptions.Item>
+ <Descriptions.Item label="瀹㈡埛">{{ ticketData.customerName }}</Descriptions.Item>
+ <Descriptions.Item label="閿�鍞鍗曞彿">{{ ticketData.saleOrderNo || '-' }}</Descriptions.Item>
+ </Descriptions>
+ </Card>
+
+ <Card v-if="needReturnItems.length" title="閫�璐ф槑缁�" class="mb-4">
+ <div class="overflow-x-auto">
+ <table class="w-full border text-sm">
+ <thead class="bg-gray-50">
+ <tr>
+ <th class="border p-2 text-left">鐗╂枡缂栫爜</th>
+ <th class="border p-2 text-left">鐗╂枡鍚嶇О</th>
+ <th class="border p-2 text-left">鎵规鍙�</th>
+ <th class="border p-2 text-right">閫�璐ф暟閲�</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="item in needReturnItems" :key="item.id">
+ <td class="border p-2">{{ item.itemCode }}</td>
+ <td class="border p-2">{{ item.itemName }}</td>
+ <td class="border p-2">{{ item.batchCode || '-' }}</td>
+ <td class="border p-2 text-right">{{ item.returnQuantity || '-' }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </Card>
+
+ <Card title="閫�璐т俊鎭�" class="mb-4">
+ <CreateForm />
+ <div class="mt-4">
+ <Button type="primary" :loading="submitting" @click="handleCreateReturn">
+ 鎻愪氦閫�璐х敵璇�
+ </Button>
+ </div>
+ </Card>
+ </template>
+
+ <!-- 閫夋嫨宸ュ崟 -->
+ <template v-else-if="isCreate && !ticketData && !loading">
+ <Card title="閫夋嫨鍞悗宸ュ崟" class="mb-4">
+ <div class="flex items-center gap-3">
+ <span class="text-sm text-gray-500">鍞悗宸ュ崟锛�</span>
+ <AftersalesTicketSelect class="!w-64" @change="handleTicketChange" />
+ </div>
+ </Card>
+ </template>
+
+ <!-- 鏌ョ湅妯″紡 -->
+ <template v-else-if="!isCreate && returnData">
+ <Card title="鍩烘湰淇℃伅" class="mb-4">
+ <Descriptions :column="3" bordered size="small">
+ <Descriptions.Item label="閫�璐у崟鍙�">{{ returnData.no }}</Descriptions.Item>
+ <Descriptions.Item label="鐘舵��">
+ <Tag :color="statusMap[returnData.status!]?.color">
+ {{ statusMap[returnData.status!]?.label }}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="瀹㈡埛">{{ returnData.customerName || '-' }}</Descriptions.Item>
+ <Descriptions.Item label="鍏宠仈宸ュ崟">
+ <Button type="link" class="!p-0" @click="router.push({ name: 'AftersalesTicketDetail', params: { id: returnData.ticketId } })">
+ {{ returnData.ticketId }}
+ </Button>
+ </Descriptions.Item>
+ <Descriptions.Item label="閫�璐х被鍨�">
+ <Tag>{{ returnData.returnType ? returnTypeMap[returnData.returnType] : '-' }}</Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="MES閫�璐у崟鍙�">
+ {{ returnData.mesReturnCode || '-' }}
+ </Descriptions.Item>
+ <Descriptions.Item label="閫�璐у師鍥�" :span="3">
+ {{ returnData.returnReason || '-' }}
+ </Descriptions.Item>
+ <Descriptions.Item label="缂洪櫡鍒嗙被">{{ returnData.defectCategory || '-' }}</Descriptions.Item>
+ <Descriptions.Item label="搴旈��鎬婚噾棰�">
+ {{ returnData.totalReturnPrice ? `楼${returnData.totalReturnPrice}` : '-' }}
+ </Descriptions.Item>
+ <Descriptions.Item label="瀹為��閲戦">
+ {{ returnData.actualRefundPrice ? `楼${returnData.actualRefundPrice}` : '-' }}
+ </Descriptions.Item>
+ <Descriptions.Item label="璐ㄦ缁撹">{{ returnData.qualityResult || '-' }}</Descriptions.Item>
+ <Descriptions.Item label="鍏ュ簱鏃堕棿">{{ returnData.returnInboundTime || '-' }}</Descriptions.Item>
+ <Descriptions.Item label="閫�娆炬椂闂�">{{ returnData.refundTime || '-' }}</Descriptions.Item>
+ <Descriptions.Item label="澶囨敞" :span="3">
+ {{ returnData.remark || '-' }}
+ </Descriptions.Item>
+ </Descriptions>
+ </Card>
+
+ <Card v-if="returnData?.items?.length" title="閫�璐ф槑缁�" class="mb-4">
+ <div class="overflow-x-auto">
+ <table class="w-full border text-sm">
+ <thead class="bg-gray-50">
+ <tr>
+ <th class="border p-2 text-left">鐗╂枡缂栫爜</th>
+ <th class="border p-2 text-left">鐗╂枡鍚嶇О</th>
+ <th class="border p-2 text-left">鎵规鍙�</th>
+ <th class="border p-2 text-right">閫�璐ф暟閲�</th>
+ <th class="border p-2 text-right">鍗曚环</th>
+ <th class="border p-2 text-right">閫�璐ч噾棰�</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="item in returnData.items" :key="item.id">
+ <td class="border p-2">{{ item.itemCode }}</td>
+ <td class="border p-2">{{ item.itemName }}</td>
+ <td class="border p-2">{{ item.batchCode || '-' }}</td>
+ <td class="border p-2 text-right">{{ item.returnQuantity }}</td>
+ <td class="border p-2 text-right">{{ item.unitPrice ? `楼${item.unitPrice}` : '-' }}</td>
+ <td class="border p-2 text-right">{{ item.returnPrice ? `楼${item.returnPrice}` : '-' }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </Card>
+ </template>
+
+ <template #extra>
+ <div v-if="!isCreate && isDraft" class="flex gap-2">
+ <Popconfirm
+ title="纭瀹℃牳閫氳繃璇ラ��璐х敵璇凤紵瀹℃牳鍚庡皢鑷姩鍚屾MES鍒涘缓閫�璐у崟銆�"
+ @confirm="handleApprove"
+ >
+ <Button type="primary">瀹℃牳閫氳繃</Button>
+ </Popconfirm>
+ </div>
+ </template>
+ </Page>
+</template>
diff --git a/src/views/aftersales/return/index.vue b/src/views/aftersales/return/index.vue
new file mode 100644
index 0000000..b2ed5b7
--- /dev/null
+++ b/src/views/aftersales/return/index.vue
@@ -0,0 +1,160 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { AftersalesReturnApi } from '#/api/aftersales/return';
+
+import { useRouter } from 'vue-router';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { Button, message, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { approveReturn, getReturnPage } from '#/api/aftersales/return';
+
+import {
+ AftersaleReturnStatusEnum,
+ useReturnGridColumns,
+ useReturnGridFormSchema,
+} from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: Form,
+ destroyOnClose: true,
+});
+
+const router = useRouter();
+
+/** 鍒涘缓閫�璐х敵璇凤紙浠庡伐鍗曞垪琛ㄩ�夋嫨宸ュ崟鍚庤烦杞級 */
+function handleCreate() {
+ router.push({ name: 'AftersalesReturnDetail', params: { id: 'new' } });
+}
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鏌ョ湅閫�璐ц鎯� */
+function handleDetail(row: AftersalesReturnApi.Return) {
+ formModalApi.setData({ formType: 'detail', id: row.id }).open();
+}
+
+/** 瀹℃牳閫氳繃 */
+async function handleApprove(row: AftersalesReturnApi.Return) {
+ const hideLoading = message.loading({
+ content: `姝e湪瀹℃牳閫�璐у崟 [${row.no}]`,
+ duration: 0,
+ });
+ try {
+ await approveReturn(row.id!);
+ message.success(`閫�璐у崟 [${row.no}] 瀹℃牳閫氳繃`);
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useReturnGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useReturnGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getReturnPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<AftersalesReturnApi.Return>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+
+ <Grid table-title="鍞悗閫�璐у垪琛�">
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: '鍒涘缓閫�璐х敵璇�',
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['aftersales:return:create'],
+ onClick: handleCreate,
+ },
+ ]"
+ />
+ </template>
+ <template #no="{ row }">
+ <Button type="link" @click="handleDetail(row)">
+ {{ row.no }}
+ </Button>
+ </template>
+ <template #returnType="{ row }">
+ <Tag v-if="row.returnType === 1" color="blue">閫�娆鹃��璐�</Tag>
+ <Tag v-else-if="row.returnType === 2" color="purple">鎹㈣揣</Tag>
+ <Tag v-else-if="row.returnType === 3" color="orange">浠呴��娆�</Tag>
+ </template>
+ <template #status="{ row }">
+ <Tag v-if="row.status === 0">鑽夌</Tag>
+ <Tag v-else-if="row.status === 10" color="processing">閿�鍞鏍镐腑</Tag>
+ <Tag v-else-if="row.status === 20" color="processing">宸插悓姝ES</Tag>
+ <Tag v-else-if="row.status === 30" color="warning">妫�楠屼腑</Tag>
+ <Tag v-else-if="row.status === 40" color="warning">妫�楠屽畬鎴�</Tag>
+ <Tag v-else-if="row.status === 50" color="success">宸插叆搴�</Tag>
+ <Tag v-else-if="row.status === 60" color="success">閫�娆�/鎹㈣揣瀹屾垚</Tag>
+ <Tag v-else-if="row.status === 70">宸插叧闂�</Tag>
+ <Tag v-else-if="row.status === 99" color="error">宸插彇娑�</Tag>
+ </template>
+ <template #qualityResult="{ row }">
+ <span>{{ row.qualityResult || '-' }}</span>
+ </template>
+ <template #ticketId="{ row }">
+ <Button type="link" @click="router.push({ name: 'AftersalesTicketDetail', params: { id: row.ticketId } })">
+ {{ row.ticketId }}
+ </Button>
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '鏌ョ湅璇︽儏',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ onClick: handleDetail.bind(null, row),
+ },
+ {
+ label: '瀹℃牳閫氳繃',
+ type: 'link',
+ icon: ACTION_ICON.AUDIT,
+ ifShow: row.status === AftersaleReturnStatusEnum.DRAFT,
+ popConfirm: {
+ title: `纭瀹℃牳閫氳繃閫�璐у崟 [${row.no}]锛焋,
+ confirm: handleApprove.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/aftersales/return/modules/form.vue b/src/views/aftersales/return/modules/form.vue
new file mode 100644
index 0000000..b25821d
--- /dev/null
+++ b/src/views/aftersales/return/modules/form.vue
@@ -0,0 +1,142 @@
+<script lang="ts" setup>
+import type { ReturnFormType } from '../data';
+
+import type { AftersalesReturnApi } from '#/api/aftersales/return';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { Button, Divider, message, Popconfirm } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { approveReturn, getReturn } from '#/api/aftersales/return';
+
+import {
+ AftersaleReturnStatusEnum,
+ useReturnFormSchema,
+ useReturnItemGridColumns,
+} from '../data';
+
+defineOptions({ name: 'AftersalesReturnForm' });
+
+const emit = defineEmits(['success']);
+const formType = ref<ReturnFormType>('detail');
+const formData = ref<AftersalesReturnApi.Return>();
+const isDraft = ref(false);
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: {
+ class: 'w-full',
+ },
+ formItemClass: 'col-span-1',
+ labelWidth: 110,
+ },
+ layout: 'horizontal',
+ schema: [],
+ showDefaultActions: false,
+ wrapperClass: 'grid-cols-3',
+});
+
+const [ItemsGrid, itemsGridApi] = useVbenVxeGrid({
+ gridOptions: {
+ columns: useReturnItemGridColumns(),
+ height: 300,
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async () => {
+ if (!formData.value?.items) {
+ return { list: [], total: 0 };
+ }
+ return {
+ list: formData.value.items,
+ total: formData.value.items.length,
+ };
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: false,
+ },
+ } as VxeTableGridOptions<AftersalesReturnApi.ReturnItem>,
+});
+
+/** 瀹℃牳閫氳繃 */
+async function handleApprove() {
+ if (!formData.value?.id) {
+ return;
+ }
+ modalApi.lock();
+ try {
+ await approveReturn(formData.value.id);
+ message.success('瀹℃牳閫氳繃鎴愬姛');
+ await modalApi.close();
+ emit('success');
+ } finally {
+ modalApi.unlock();
+ }
+}
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ await modalApi.close();
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ formData.value = undefined;
+ isDraft.value = false;
+ return;
+ }
+ const data = modalApi.getData<{ formType: ReturnFormType; id?: number }>();
+ formType.value = data.formType;
+ formApi.setState({
+ schema: useReturnFormSchema(formType.value),
+ });
+ formApi.setDisabled(true);
+ modalApi.setState({ showConfirmButton: false });
+ if (data?.id) {
+ modalApi.lock();
+ try {
+ formData.value = await getReturn(data.id);
+ isDraft.value =
+ formData.value?.status === AftersaleReturnStatusEnum.DRAFT;
+ await formApi.setValues(formData.value as Record<string, unknown>);
+ itemsGridApi.query();
+ } finally {
+ modalApi.unlock();
+ }
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="'鍞悗閫�璐ц鎯�'" class="w-3/5">
+ <Form class="mx-4" />
+ <template v-if="formData?.items?.length">
+ <Divider>閫�璐ф槑缁�</Divider>
+ <div class="mx-4">
+ <ItemsGrid table-title="鏄庣粏鍒楄〃" />
+ </div>
+ </template>
+ <template #prepend-footer>
+ <div class="flex flex-auto items-center gap-2">
+ <Popconfirm
+ v-if="isDraft"
+ title="纭瀹℃牳閫氳繃璇ラ��璐х敵璇凤紵"
+ @confirm="handleApprove"
+ >
+ <Button type="primary">瀹℃牳閫氳繃</Button>
+ </Popconfirm>
+ </div>
+ </template>
+ </Modal>
+</template>
diff --git a/src/views/aftersales/ticket/components/index.ts b/src/views/aftersales/ticket/components/index.ts
new file mode 100644
index 0000000..c9f89e0
--- /dev/null
+++ b/src/views/aftersales/ticket/components/index.ts
@@ -0,0 +1,2 @@
+export { default as AftersalesTicketSelect } from './select.vue';
+export { default as AftersalesTicketSelectDialog } from './select-dialog.vue';
diff --git a/src/views/aftersales/ticket/components/select-dialog.vue b/src/views/aftersales/ticket/components/select-dialog.vue
new file mode 100644
index 0000000..8b7738b
--- /dev/null
+++ b/src/views/aftersales/ticket/components/select-dialog.vue
@@ -0,0 +1,163 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
+
+import { nextTick, ref } from 'vue';
+
+import { message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getTicketPage } from '#/api/aftersales/ticket';
+
+defineOptions({ name: 'AftersalesTicketSelectDialog' });
+
+const emit = defineEmits<{
+ selected: [rows: AftersalesTicketApi.Ticket[]];
+}>();
+
+const open = ref(false);
+const multiple = ref(true);
+const selectedRows = ref<AftersalesTicketApi.Ticket[]>([]);
+const preSelectedIds = ref<number[]>([]);
+
+function getTableRows() {
+ return gridApi.grid.getTableData().fullData as AftersalesTicketApi.Ticket[];
+}
+
+function getMultipleSelectedRows() {
+ const selectedMap = new Map<number, AftersalesTicketApi.Ticket>();
+ const records = [
+ ...(gridApi.grid.getCheckboxReserveRecords?.() ?? []),
+ ...(gridApi.grid.getCheckboxRecords?.() ?? []),
+ ] as AftersalesTicketApi.Ticket[];
+ records.forEach((row) => {
+ if (row.id) selectedMap.set(row.id, row);
+ });
+ return [...selectedMap.values()];
+}
+
+function handleCheckboxSelectChange() {
+ selectedRows.value = getMultipleSelectedRows();
+}
+
+function handleRadioChange(row: AftersalesTicketApi.Ticket) {
+ selectedRows.value = [row];
+}
+
+async function handleCellDblclick({ row }: { row: AftersalesTicketApi.Ticket }) {
+ if (multiple.value) {
+ const selected = gridApi.grid.isCheckedByCheckboxRow(row);
+ await gridApi.grid.setCheckboxRow(row, !selected);
+ selectedRows.value = getMultipleSelectedRows();
+ return;
+ }
+ selectedRows.value = [row];
+ await gridApi.grid.setRadioRow(row);
+ handleConfirm();
+}
+
+async function applyPreSelection() {
+ if (preSelectedIds.value.length === 0) return;
+ const rows = getTableRows();
+ for (const row of rows) {
+ if (row.id && preSelectedIds.value.includes(row.id)) {
+ if (multiple.value) {
+ await gridApi.grid.setCheckboxRow(row, true);
+ } else {
+ await gridApi.grid.setRadioRow(row);
+ selectedRows.value = [row];
+ return;
+ }
+ }
+ }
+ if (multiple.value) selectedRows.value = getMultipleSelectedRows();
+}
+
+function useGridColumns(isMultiple: boolean): VxeTableGridOptions<AftersalesTicketApi.Ticket>['columns'] {
+ return [
+ { type: isMultiple ? 'checkbox' : 'radio', width: 50 },
+ { field: 'no', title: '宸ュ崟鍗曞彿', minWidth: 150 },
+ { field: 'name', title: '宸ュ崟涓婚', minWidth: 150 },
+ { field: 'customerName', title: '瀹㈡埛', minWidth: 120 },
+ { field: 'saleOrderNo', title: '閿�鍞鍗曞彿', minWidth: 130 },
+ { field: 'createTime', title: '鍒涘缓鏃堕棿', width: 160, formatter: 'formatDate' },
+ ];
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ gridOptions: {
+ columns: useGridColumns(true),
+ height: 400,
+ keepSource: true,
+ checkboxConfig: { highlight: true, reserve: true },
+ radioConfig: { highlight: true, trigger: 'row' },
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getTicketPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: { keyField: 'id', isHover: true },
+ } as VxeTableGridOptions<AftersalesTicketApi.Ticket>,
+ gridEvents: {
+ cellDblclick: handleCellDblclick,
+ checkboxAll: handleCheckboxSelectChange,
+ checkboxChange: handleCheckboxSelectChange,
+ radioChange: ({ row }: { row: AftersalesTicketApi.Ticket }) => handleRadioChange(row),
+ },
+});
+
+async function resetQueryState() {
+ selectedRows.value = [];
+ await gridApi.grid.clearCheckboxRow();
+ await gridApi.grid.clearCheckboxReserve();
+ await gridApi.grid.clearRadioRow();
+}
+
+async function openModal(selectedIds?: number[], options?: { multiple?: boolean }) {
+ open.value = true;
+ multiple.value = options?.multiple ?? true;
+ preSelectedIds.value = selectedIds || [];
+ await nextTick();
+ gridApi.setGridOptions({ columns: useGridColumns(multiple.value) });
+ await resetQueryState();
+ await gridApi.query();
+ await nextTick();
+ await applyPreSelection();
+}
+
+async function closeModal() {
+ open.value = false;
+ await resetQueryState();
+}
+
+function handleConfirm() {
+ const rows = multiple.value ? getMultipleSelectedRows() : selectedRows.value;
+ if (rows.length === 0) {
+ message.warning(multiple.value ? '璇疯嚦灏戦�夋嫨涓�鏉℃暟鎹�' : '璇烽�夋嫨涓�鏉℃暟鎹�');
+ return;
+ }
+ emit('selected', multiple.value ? rows : [rows[0]!]);
+ open.value = false;
+}
+
+defineExpose({ open: openModal });
+</script>
+
+<template>
+ <Modal
+ v-model:open="open"
+ title="鍞悗宸ュ崟閫夋嫨"
+ width="70%"
+ :destroy-on-close="true"
+ @ok="handleConfirm"
+ @cancel="closeModal"
+ >
+ <Grid table-title="鍞悗宸ュ崟鍒楄〃" />
+ </Modal>
+</template>
diff --git a/src/views/aftersales/ticket/components/select.vue b/src/views/aftersales/ticket/components/select.vue
new file mode 100644
index 0000000..cf1b077
--- /dev/null
+++ b/src/views/aftersales/ticket/components/select.vue
@@ -0,0 +1,111 @@
+<script lang="ts" setup>
+import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
+
+import { computed, ref, useAttrs, watch } from 'vue';
+
+import { IconifyIcon } from '@vben/icons';
+
+import { Input, Tooltip } from 'ant-design-vue';
+
+import { getTicket } from '#/api/aftersales/ticket';
+
+import AftersalesTicketSelectDialog from './select-dialog.vue';
+
+defineOptions({ name: 'AftersalesTicketSelect', inheritAttrs: false });
+
+const props = withDefaults(
+ defineProps<{
+ allowClear?: boolean;
+ disabled?: boolean;
+ modelValue?: number;
+ placeholder?: string;
+ }>(),
+ {
+ allowClear: true,
+ disabled: false,
+ modelValue: undefined,
+ placeholder: '璇烽�夋嫨鍞悗宸ュ崟',
+ },
+);
+
+const emit = defineEmits(['change', 'update:modelValue']);
+
+const attrs = useAttrs();
+const dialogRef = ref<InstanceType<typeof AftersalesTicketSelectDialog>>();
+const hovering = ref(false);
+const selectedItem = ref<AftersalesTicketApi.Ticket>();
+
+const displayLabel = computed(() => selectedItem.value?.no ?? '');
+const showClear = computed(
+ () => props.allowClear && !props.disabled && hovering.value && props.modelValue !== null,
+);
+
+async function resolveItemById(id: number | undefined) {
+ if (!id) { selectedItem.value = undefined; return; }
+ if (selectedItem.value?.id === id) return;
+ selectedItem.value = await getTicket(id);
+}
+
+watch(() => props.modelValue, (value) => { resolveItemById(value); }, { immediate: true });
+
+function clearSelected() {
+ selectedItem.value = undefined;
+ emit('update:modelValue', undefined);
+ emit('change', undefined);
+}
+
+function handleClick(event: MouseEvent) {
+ if (props.disabled) return;
+ const target = event.target as HTMLElement;
+ if (showClear.value && target.closest('.ant-input-suffix')) {
+ event.stopPropagation();
+ clearSelected();
+ return;
+ }
+ const selectedIds = (props.modelValue === null ? [] : [props.modelValue]) as number[];
+ dialogRef.value?.open(selectedIds, { multiple: false });
+}
+
+function handleSelected(rows: AftersalesTicketApi.Ticket[]) {
+ const item = rows[0];
+ if (!item) return;
+ selectedItem.value = item;
+ emit('update:modelValue', item.id);
+ emit('change', item);
+}
+</script>
+
+<template>
+ <div
+ v-bind="attrs"
+ class="w-full"
+ :class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
+ @click="handleClick"
+ @mouseenter="hovering = true"
+ @mouseleave="hovering = false"
+ >
+ <Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
+ <template #title>
+ <div v-if="selectedItem" class="leading-6">
+ <div>宸ュ崟鍙凤細{{ selectedItem.no || '-' }}</div>
+ <div>涓婚锛歿{ selectedItem.name || '-' }}</div>
+ <div>瀹㈡埛锛歿{ selectedItem.customerName || '-' }}</div>
+ </div>
+ </template>
+ <Input
+ :disabled="disabled"
+ :placeholder="placeholder"
+ :value="displayLabel"
+ readonly
+ >
+ <template #suffix>
+ <IconifyIcon
+ class="size-4"
+ :icon="showClear ? 'lucide:circle-x' : 'lucide:search'"
+ />
+ </template>
+ </Input>
+ </Tooltip>
+ </div>
+ <AftersalesTicketSelectDialog ref="dialogRef" @selected="handleSelected" />
+</template>
diff --git a/src/views/aftersales/ticket/data.ts b/src/views/aftersales/ticket/data.ts
new file mode 100644
index 0000000..226e4a6
--- /dev/null
+++ b/src/views/aftersales/ticket/data.ts
@@ -0,0 +1,489 @@
+import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
+import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
+
+import { h, markRaw } from 'vue';
+
+import { DICT_TYPE } from '@vben/constants';
+import { getDictOptions } from '@vben/hooks';
+
+import { Button, Tag } from 'ant-design-vue';
+
+import { z } from '#/adapter/form';
+import CrmCustomerSelect from '#/components/crm-customer-select.vue';
+import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components';
+import { MdItemSelect } from '#/views/mes/md/item/components';
+import { WmBatchSelect } from '#/views/wls/batch/components';
+import { UserSelect } from '#/views/system/user/components';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type TicketFormType = 'create' | 'detail' | 'update';
+
+/** 闂绫诲瀷閫夐」 */
+const ISSUE_TYPE_OPTIONS = [
+ { label: '涓�鑸棶棰�', value: 1 },
+ { label: '缁翠慨闂', value: 2 },
+ { label: '閫�璐ч棶棰�', value: 3 },
+];
+
+/** 涓ラ噸绋嬪害閫夐」 */
+const SEVERITY_LEVEL_OPTIONS = [
+ { label: '杞诲井', value: 0 },
+ { label: '涓�鑸�', value: 1 },
+ { label: '涓ラ噸', value: 2 },
+ { label: '鑷村懡', value: 3 },
+];
+
+function isHeaderReadonly(formType: TicketFormType): boolean {
+ return formType === 'detail';
+}
+
+/** 宸ュ崟琛ㄥ崟 */
+export function useTicketFormSchema(
+ formType: TicketFormType,
+ formApi?: VbenFormApi,
+ onSaleOrderChange?: (order: Record<string, unknown> | undefined) => void,
+): VbenFormSchema[] {
+ const readonly = isHeaderReadonly(formType);
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'status',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'name',
+ label: '宸ュ崟涓婚',
+ component: 'Input',
+ componentProps: {
+ placeholder: '璇疯緭鍏ュ伐鍗曚富棰�',
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'saleOrderId',
+ label: '閿�鍞鍗曞彿',
+ component: markRaw(ErpSaleOrderSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨閿�鍞鍗�',
+ outStatus: 3,
+ onChange: (item: Record<string, unknown> | undefined) => {
+ if (formApi) {
+ formApi.setFieldValue('saleOrderNo', item?.no ?? '');
+ if (item?.customerId) {
+ formApi.setFieldValue('customerId', item.customerId);
+ }
+ }
+ onSaleOrderChange?.(item);
+ },
+ },
+ },
+ {
+ fieldName: 'saleOrderNo',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'customerId',
+ label: '瀹㈡埛',
+ component: markRaw(CrmCustomerSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨瀹㈡埛',
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'contactId',
+ label: '鑱旂郴浜�',
+ component: 'Input',
+ componentProps: {
+ placeholder: '璇疯緭鍏ヨ仈绯讳汉',
+ },
+ },
+ {
+ fieldName: 'handlerUserId',
+ label: '澶勭悊浜�',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨澶勭悊浜�',
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'contractId',
+ label: '鍚堝悓缂栧彿',
+ component: 'Input',
+ componentProps: {
+ placeholder: '璇疯緭鍏ュ悎鍚岀紪鍙�',
+ },
+ },
+ {
+ fieldName: 'issueDescription',
+ label: '闂鎻忚堪',
+ component: 'Textarea',
+ formItemClass: 'col-span-3',
+ componentProps: {
+ placeholder: '璇疯緭鍏ラ棶棰樻弿杩�',
+ rows: 3,
+ },
+ },
+ {
+ fieldName: 'issueType',
+ label: '闂绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ options: ISSUE_TYPE_OPTIONS,
+ placeholder: '璇烽�夋嫨闂绫诲瀷',
+ },
+ dependencies: {
+ triggerFields: ['status'],
+ show: () => !readonly || formType === 'detail',
+ },
+ },
+ {
+ fieldName: 'issueCategory',
+ label: '闂鍒嗙被',
+ component: 'Input',
+ componentProps: {
+ placeholder: '璇疯緭鍏ラ棶棰樺垎绫伙紙濡傚瑙傜己闄枫�佸姛鑳芥晠闅滐級',
+ },
+ },
+ {
+ fieldName: 'severityLevel',
+ label: '涓ラ噸绋嬪害',
+ component: 'Select',
+ componentProps: {
+ options: SEVERITY_LEVEL_OPTIONS,
+ placeholder: '璇烽�夋嫨涓ラ噸绋嬪害',
+ },
+ },
+ {
+ fieldName: 'remark',
+ label: '澶囨敞',
+ component: 'Textarea',
+ formItemClass: 'col-span-3',
+ componentProps: {
+ placeholder: '璇疯緭鍏ュ娉�',
+ rows: 3,
+ },
+ },
+ ];
+}
+
+/** 宸ュ崟鍒楄〃鎼滅储琛ㄥ崟 */
+export function useTicketGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'no',
+ label: '宸ュ崟鍗曞彿',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ュ伐鍗曞崟鍙�',
+ },
+ },
+ {
+ fieldName: 'name',
+ label: '宸ュ崟涓婚',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ュ伐鍗曚富棰�',
+ },
+ },
+ {
+ fieldName: 'customerId',
+ label: '瀹㈡埛',
+ component: markRaw(CrmCustomerSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨瀹㈡埛',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '宸ュ崟鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.AFTERSALE_TICKET_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨宸ュ崟鐘舵��',
+ },
+ },
+ {
+ fieldName: 'issueType',
+ label: '闂绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: ISSUE_TYPE_OPTIONS,
+ placeholder: '璇烽�夋嫨闂绫诲瀷',
+ },
+ },
+ ];
+}
+
+/** 宸ュ崟鍒楄〃瀛楁 */
+export function useTicketGridColumns(): VxeTableGridOptions<AftersalesTicketApi.Ticket>['columns'] {
+ return [
+ {
+ field: 'no',
+ title: '宸ュ崟鍗曞彿',
+ minWidth: 180,
+ slots: { default: 'no' },
+ },
+ {
+ field: 'name',
+ title: '宸ュ崟涓婚',
+ minWidth: 180,
+ },
+ {
+ field: 'customerName',
+ title: '瀹㈡埛',
+ minWidth: 150,
+ },
+ {
+ field: 'saleOrderNo',
+ title: '閿�鍞鍗曞彿',
+ minWidth: 150,
+ },
+ {
+ field: 'issueType',
+ title: '闂绫诲瀷',
+ width: 100,
+ slots: { default: 'issueType' },
+ },
+ {
+ field: 'severityLevel',
+ title: '涓ラ噸绋嬪害',
+ width: 100,
+ slots: { default: 'severityLevel' },
+ },
+ {
+ field: 'status',
+ title: '宸ュ崟鐘舵��',
+ width: 100,
+ slots: { default: 'status' },
+ },
+ {
+ field: 'ownerUserName',
+ title: '璐熻矗浜�',
+ minWidth: 100,
+ },
+ {
+ field: 'handlerUserName',
+ title: '澶勭悊浜�',
+ minWidth: 100,
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ width: 180,
+ formatter: 'formatDate',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 240,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 宸ュ崟鏄庣粏琛岃〃鏍煎瓧娈� */
+export function useTicketItemGridColumns(
+ editable: boolean,
+): VxeTableGridOptions<AftersalesTicketApi.TicketItem>['columns'] {
+ return [
+ {
+ field: 'itemCode',
+ title: '鐗╂枡缂栫爜',
+ minWidth: 120,
+ },
+ {
+ field: 'itemName',
+ title: '鐗╂枡鍚嶇О',
+ minWidth: 140,
+ },
+ {
+ field: 'batchCode',
+ title: '鎵规鍙�',
+ minWidth: 120,
+ },
+ {
+ field: 'saleQuantity',
+ title: '閿�鍞暟閲�',
+ width: 100,
+ },
+ {
+ field: 'returnQuantity',
+ title: '閫�璐ф暟閲�',
+ width: 100,
+ },
+ {
+ field: 'needRepair',
+ title: '闇�瑕佺淮淇�',
+ width: 90,
+ slots: { default: 'needRepair' },
+ },
+ {
+ field: 'needReturn',
+ title: '闇�瑕侀��璐�',
+ width: 90,
+ slots: { default: 'needReturn' },
+ },
+ ...(editable
+ ? [
+ {
+ title: '鎿嶄綔',
+ width: 120,
+ fixed: 'right',
+ slots: { default: 'itemActions' },
+ } as const,
+ ]
+ : []),
+ ];
+}
+
+/** 宸ュ崟鏄庣粏琛岃〃鍗� */
+export function useTicketItemFormSchema(
+ clientId?: number,
+ salesOrderCode?: string,
+ orderItems?: ErpSaleOrderApi.SaleOrderItem[],
+ formApi?: VbenFormApi,
+): VbenFormSchema[] {
+ const hasOrderItems = orderItems && orderItems.length > 0;
+ return [
+ {
+ fieldName: 'itemId',
+ label: '鐗╂枡',
+ component: hasOrderItems ? 'Select' : markRaw(MdItemSelect),
+ componentProps: hasOrderItems
+ ? {
+ placeholder: '璇烽�夋嫨鐗╂枡',
+ options: orderItems!.map((oi) => ({
+ label: `${oi.productName || ''} (${oi.productBarCode || oi.productId})`,
+ value: oi.productId,
+ })),
+ showSearch: true,
+ onChange: (value: number) => {
+ const oi = orderItems!.find((o) => o.productId === value);
+ if (oi && formApi) {
+ formApi.setValues({
+ itemName: oi.productName,
+ itemCode: oi.productBarCode || '',
+ saleQuantity: oi.count,
+ batchCode: oi.batchCode || '',
+ });
+ }
+ },
+ }
+ : {
+ placeholder: '璇烽�夋嫨鐗╂枡',
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'itemCode',
+ component: 'Input',
+ dependencies: { triggerFields: [''], show: () => false },
+ },
+ {
+ fieldName: 'itemName',
+ component: 'Input',
+ dependencies: { triggerFields: [''], show: () => false },
+ },
+ ...(hasOrderItems
+ ? [
+ {
+ fieldName: 'batchCode',
+ label: '鎵规',
+ component: 'Input',
+ componentProps: {
+ disabled: true,
+ placeholder: '鑷姩浠庤鍗曞甫鍏�',
+ },
+ },
+ ]
+ : [
+ {
+ fieldName: 'batchId',
+ label: '鎵规',
+ component: markRaw(WmBatchSelect),
+ componentProps: {
+ clientId,
+ placeholder: '璇烽�夋嫨鎵规',
+ salesOrderCode,
+ },
+ dependencies: {
+ triggerFields: ['itemId'],
+ componentProps: (values: Record<string, unknown>) => ({
+ clientId,
+ itemId: values.itemId,
+ placeholder: '璇烽�夋嫨鎵规',
+ salesOrderCode,
+ }),
+ },
+ },
+ ]),
+ {
+ fieldName: 'saleQuantity',
+ label: '閿�鍞暟閲�',
+ component: 'InputNumber',
+ componentProps: {
+ class: '!w-full',
+ min: 0,
+ placeholder: '璇疯緭鍏ラ攢鍞暟閲�',
+ precision: 2,
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'returnQuantity',
+ label: '閫�璐ф暟閲�',
+ component: 'InputNumber',
+ componentProps: {
+ class: '!w-full',
+ min: 0,
+ placeholder: '璇疯緭鍏ラ��璐ф暟閲�',
+ precision: 2,
+ },
+ },
+ {
+ fieldName: 'needRepair',
+ label: '闇�瑕佺淮淇�',
+ component: 'Switch',
+ rules: z.boolean().default(false),
+ },
+ {
+ fieldName: 'needReturn',
+ label: '闇�瑕侀��璐�',
+ component: 'Switch',
+ rules: z.boolean().default(false),
+ },
+ {
+ fieldName: 'remark',
+ label: '澶囨敞',
+ component: 'Textarea',
+ formItemClass: 'col-span-3',
+ componentProps: {
+ placeholder: '璇疯緭鍏ュ娉�',
+ rows: 2,
+ },
+ },
+ ];
+}
diff --git a/src/views/aftersales/ticket/detail.vue b/src/views/aftersales/ticket/detail.vue
new file mode 100644
index 0000000..09eb0b4
--- /dev/null
+++ b/src/views/aftersales/ticket/detail.vue
@@ -0,0 +1,331 @@
+<script lang="ts" setup>
+import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
+
+import { computed, onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { Page } from '@vben/common-ui';
+import { AftersaleIssueTypeEnum, AftersaleTicketStatusEnum } from '@vben/constants';
+
+import { Button, Card, Descriptions, Input, message, Popconfirm, Select, Tag } from 'ant-design-vue';
+
+import {
+ assignTicket,
+ cancelTicket,
+ classifyTicket,
+ closeTicket,
+ getTicket,
+ resolveTicket,
+} from '#/api/aftersales/ticket';
+
+defineOptions({ name: 'AftersalesTicketDetail' });
+
+const route = useRoute();
+const router = useRouter();
+const loading = ref(false);
+const ticket = ref<AftersalesTicketApi.Ticket>();
+
+const ticketId = computed(() => Number(route.params.id));
+
+const isDraft = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.DRAFT);
+const isProcessing = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PROCESSING);
+const isPendingReturn = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PENDING_RETURN);
+const isPendingRefund = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PENDING_REFUND);
+const isResolved = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.RESOLVED);
+
+// 鍒嗛厤澶勭悊浜�
+const showAssignInput = ref(false);
+const assignUserId = ref<number>();
+
+// 闂鍒ゅ畾
+const showClassifyInput = ref(false);
+const classifyIssueType = ref<number>();
+const classifyIssueCategory = ref('');
+const classifySeverityLevel = ref<number>();
+
+const issueTypeOptions = [
+ { label: '涓�鑸棶棰�', value: 1 },
+ { label: '缁翠慨闂', value: 2 },
+ { label: '閫�璐ч棶棰�', value: 3 },
+];
+
+const severityOptions = [
+ { label: '杞诲井', value: 0 },
+ { label: '涓�鑸�', value: 1 },
+ { label: '涓ラ噸', value: 2 },
+ { label: '鑷村懡', value: 3 },
+];
+
+const statusMap: Record<number, { color: string; label: string }> = {
+ [AftersaleTicketStatusEnum.DRAFT]: { color: 'default', label: '鑽夌' },
+ [AftersaleTicketStatusEnum.PROCESSING]: { color: 'processing', label: '澶勭悊涓�' },
+ [AftersaleTicketStatusEnum.PENDING_RETURN]: { color: 'warning', label: '寰呴��璐�' },
+ [AftersaleTicketStatusEnum.PENDING_REFUND]: { color: 'warning', label: '寰呴��娆�/鎹㈣揣' },
+ [AftersaleTicketStatusEnum.RESOLVED]: { color: 'success', label: '宸插畬缁�' },
+ [AftersaleTicketStatusEnum.CLOSED]: { color: 'default', label: '宸插叧闂�' },
+ [AftersaleTicketStatusEnum.CANCELLED]: { color: 'error', label: '宸插彇娑�' },
+};
+
+const issueTypeMap: Record<number, string> = {
+ 1: '涓�鑸棶棰�',
+ 2: '缁翠慨闂',
+ 3: '閫�璐ч棶棰�',
+};
+
+const severityMap: Record<number, { color: string; label: string }> = {
+ 0: { color: 'green', label: '杞诲井' },
+ 1: { color: 'blue', label: '涓�鑸�' },
+ 2: { color: 'orange', label: '涓ラ噸' },
+ 3: { color: 'red', label: '鑷村懡' },
+};
+
+async function loadTicket() {
+ loading.value = true;
+ try {
+ ticket.value = await getTicket(ticketId.value);
+ } finally {
+ loading.value = false;
+ }
+}
+
+function toggleAssignInput() {
+ showAssignInput.value = !showAssignInput.value;
+ assignUserId.value = undefined;
+}
+
+async function handleAssign() {
+ if (!assignUserId.value) {
+ message.warning('璇疯緭鍏ュ鐞嗕汉ID');
+ return;
+ }
+ await assignTicket(ticketId.value, assignUserId.value);
+ message.success('鍒嗛厤鎴愬姛');
+ showAssignInput.value = false;
+ await loadTicket();
+}
+
+function toggleClassifyInput() {
+ showClassifyInput.value = !showClassifyInput.value;
+ if (showClassifyInput.value) {
+ classifyIssueType.value = ticket.value?.issueType;
+ classifyIssueCategory.value = ticket.value?.issueCategory || '';
+ classifySeverityLevel.value = ticket.value?.severityLevel;
+ }
+}
+
+async function handleClassify() {
+ if (!classifyIssueType.value) {
+ message.warning('璇烽�夋嫨闂绫诲瀷');
+ return;
+ }
+ await classifyTicket({
+ id: ticketId.value,
+ issueType: classifyIssueType.value,
+ issueCategory: classifyIssueCategory.value || undefined,
+ severityLevel: classifySeverityLevel.value,
+ });
+ message.success('闂鍒ゅ畾鎴愬姛');
+ showClassifyInput.value = false;
+ await loadTicket();
+}
+
+async function handleResolve() {
+ await resolveTicket(ticketId.value);
+ message.success('瀹岀粨鎴愬姛');
+ await loadTicket();
+}
+
+async function handleClose() {
+ await closeTicket(ticketId.value);
+ message.success('鍏抽棴鎴愬姛');
+ await loadTicket();
+}
+
+async function handleCancel() {
+ await cancelTicket(ticketId.value);
+ message.success('鍙栨秷鎴愬姛');
+ await loadTicket();
+}
+
+function handleCreateReturn() {
+ router.push({
+ name: 'AftersalesReturnDetail',
+ params: { id: 'new' },
+ query: { ticketId: ticketId.value },
+ });
+}
+
+function goBack() {
+ router.back();
+}
+
+onMounted(() => {
+ loadTicket();
+});
+</script>
+
+<template>
+ <Page auto-content-height :loading="loading">
+ <template #title>
+ <div class="flex items-center gap-3">
+ <Button @click="goBack">杩斿洖</Button>
+ <span class="text-lg font-bold">鍞悗宸ュ崟璇︽儏</span>
+ </div>
+ </template>
+
+ <Card v-if="ticket" title="鍩烘湰淇℃伅" class="mb-4">
+ <Descriptions :column="3" bordered size="small">
+ <Descriptions.Item label="宸ュ崟鍗曞彿">{{ ticket.no }}</Descriptions.Item>
+ <Descriptions.Item label="宸ュ崟涓婚">{{ ticket.name }}</Descriptions.Item>
+ <Descriptions.Item label="鐘舵��">
+ <Tag :color="statusMap[ticket.status!]?.color">
+ {{ statusMap[ticket.status!]?.label }}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="瀹㈡埛">{{ ticket.customerName }}</Descriptions.Item>
+ <Descriptions.Item label="閿�鍞鍗曞彿">{{ ticket.saleOrderNo }}</Descriptions.Item>
+ <Descriptions.Item label="闂绫诲瀷">
+ {{ ticket.issueType ? issueTypeMap[ticket.issueType] : '-' }}
+ </Descriptions.Item>
+ <Descriptions.Item label="涓ラ噸绋嬪害">
+ <Tag v-if="ticket.severityLevel !== undefined" :color="severityMap[ticket.severityLevel]?.color">
+ {{ severityMap[ticket.severityLevel]?.label }}
+ </Tag>
+ <span v-else>-</span>
+ </Descriptions.Item>
+ <Descriptions.Item label="璐熻矗浜�">{{ ticket.ownerUserName || '-' }}</Descriptions.Item>
+ <Descriptions.Item label="澶勭悊浜�">{{ ticket.handlerUserName || '-' }}</Descriptions.Item>
+ <Descriptions.Item label="闂鎻忚堪" :span="3">
+ {{ ticket.issueDescription || '-' }}
+ </Descriptions.Item>
+ <Descriptions.Item label="澶囨敞" :span="3">
+ {{ ticket.remark || '-' }}
+ </Descriptions.Item>
+ </Descriptions>
+ </Card>
+
+ <Card v-if="ticket?.items?.length" title="鏄庣粏琛�" class="mb-4">
+ <div class="overflow-x-auto">
+ <table class="w-full border text-sm">
+ <thead class="bg-gray-50">
+ <tr>
+ <th class="border p-2 text-left">鐗╂枡缂栫爜</th>
+ <th class="border p-2 text-left">鐗╂枡鍚嶇О</th>
+ <th class="border p-2 text-left">鎵规鍙�</th>
+ <th class="border p-2 text-right">閿�鍞暟閲�</th>
+ <th class="border p-2 text-right">閫�璐ф暟閲�</th>
+ <th class="border p-2 text-center">闇�瑕佺淮淇�</th>
+ <th class="border p-2 text-center">闇�瑕侀��璐�</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="item in ticket.items" :key="item.id">
+ <td class="border p-2">{{ item.itemCode }}</td>
+ <td class="border p-2">{{ item.itemName }}</td>
+ <td class="border p-2">{{ item.batchCode || '-' }}</td>
+ <td class="border p-2 text-right">{{ item.saleQuantity }}</td>
+ <td class="border p-2 text-right">{{ item.returnQuantity || '-' }}</td>
+ <td class="border p-2 text-center">
+ <Tag :color="item.needRepair ? 'green' : 'default'">
+ {{ item.needRepair ? '鏄�' : '鍚�' }}
+ </Tag>
+ </td>
+ <td class="border p-2 text-center">
+ <Tag :color="item.needReturn ? 'blue' : 'default'">
+ {{ item.needReturn ? '鏄�' : '鍚�' }}
+ </Tag>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </Card>
+
+ <!-- 闂鍒ゅ畾琛ㄥ崟 -->
+ <Card v-if="showClassifyInput" title="闂鍒ゅ畾" class="mb-4">
+ <div class="flex flex-wrap items-center gap-3">
+ <div class="flex items-center gap-1">
+ <span class="text-sm text-gray-500">闂绫诲瀷锛�</span>
+ <Select
+ v-model:value="classifyIssueType"
+ :options="issueTypeOptions"
+ placeholder="璇烽�夋嫨"
+ style="width: 140px"
+ />
+ </div>
+ <div class="flex items-center gap-1">
+ <span class="text-sm text-gray-500">闂鍒嗙被锛�</span>
+ <Input v-model:value="classifyIssueCategory" placeholder="濡傚瑙傜己闄�" style="width: 160px" />
+ </div>
+ <div class="flex items-center gap-1">
+ <span class="text-sm text-gray-500">涓ラ噸绋嬪害锛�</span>
+ <Select
+ v-model:value="classifySeverityLevel"
+ :options="severityOptions"
+ placeholder="璇烽�夋嫨"
+ style="width: 120px"
+ />
+ </div>
+ <Button type="primary" size="small" @click="handleClassify">纭鍒ゅ畾</Button>
+ <Button size="small" @click="toggleClassifyInput">鍙栨秷</Button>
+ </div>
+ </Card>
+
+ <template #extra>
+ <div class="flex gap-2">
+ <!-- 鍒涘缓閫�璐х敵璇� -->
+ <Button v-if="isProcessing && ticket?.issueType === AftersaleIssueTypeEnum.RETURN" type="primary" @click="handleCreateReturn">
+ 鍒涘缓閫�璐х敵璇�
+ </Button>
+
+ <!-- 鍒嗛厤澶勭悊浜� -->
+ <template v-if="isDraft || isProcessing">
+ <template v-if="!showAssignInput">
+ <Button @click="toggleAssignInput">鍒嗛厤澶勭悊浜�</Button>
+ </template>
+ <template v-else>
+ <Input
+ v-model:value="assignUserId"
+ placeholder="澶勭悊浜篒D"
+ type="number"
+ style="width: 120px"
+ @pressEnter="handleAssign"
+ />
+ <Button size="small" type="primary" @click="handleAssign">纭</Button>
+ <Button size="small" @click="toggleAssignInput">鍙栨秷</Button>
+ </template>
+ </template>
+
+ <!-- 闂鍒ゅ畾 -->
+ <Button v-if="isProcessing && !showClassifyInput" @click="toggleClassifyInput">闂鍒ゅ畾</Button>
+
+ <!-- 瀹岀粨 -->
+ <Popconfirm
+ v-if="isProcessing || isPendingReturn || isPendingRefund"
+ title="纭瀹岀粨璇ュ伐鍗曪紵"
+ @confirm="handleResolve"
+ >
+ <Button type="primary">瀹岀粨</Button>
+ </Popconfirm>
+
+ <!-- 鍏抽棴 -->
+ <Popconfirm
+ v-if="isResolved"
+ title="纭鍏抽棴璇ュ伐鍗曪紵鍏抽棴鍚庝笉鍙仮澶嶃��"
+ @confirm="handleClose"
+ >
+ <Button type="primary">鍏抽棴</Button>
+ </Popconfirm>
+
+ <!-- 鍙栨秷 -->
+ <Popconfirm
+ v-if="isDraft || isProcessing"
+ title="纭鍙栨秷璇ュ伐鍗曪紵鍙栨秷鍚庝笉鍙仮澶嶃��"
+ @confirm="handleCancel"
+ >
+ <Button danger>鍙栨秷</Button>
+ </Popconfirm>
+ </div>
+ </template>
+ </Page>
+</template>
diff --git a/src/views/aftersales/ticket/index.vue b/src/views/aftersales/ticket/index.vue
new file mode 100644
index 0000000..269a460
--- /dev/null
+++ b/src/views/aftersales/ticket/index.vue
@@ -0,0 +1,212 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
+
+import { ref } from 'vue';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+import { AftersaleTicketStatusEnum } from '@vben/constants';
+
+import { Button, message, Tabs, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteTicket, getTicketPage } from '#/api/aftersales/ticket';
+import { cancelTicket } from '#/api/aftersales/ticket';
+import { $t } from '#/locales';
+
+import { useTicketGridColumns, useTicketGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+const sceneType = ref('1');
+
+/** 澶勭悊鍦烘櫙绫诲瀷鐨勫垏鎹� */
+function handleChangeSceneType(key: number | string) {
+ sceneType.value = key.toString();
+ gridApi.query();
+}
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: Form,
+ destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鍒涘缓宸ュ崟 */
+function handleCreate() {
+ formModalApi.setData({ formType: 'create' }).open();
+}
+
+/** 鏌ョ湅宸ュ崟 */
+function handleDetail(row: AftersalesTicketApi.Ticket) {
+ formModalApi.setData({ formType: 'detail', id: row.id }).open();
+}
+
+/** 缂栬緫宸ュ崟 */
+function handleEdit(row: AftersalesTicketApi.Ticket) {
+ formModalApi.setData({ formType: 'update', id: row.id }).open();
+}
+
+/** 鍙栨秷宸ュ崟 */
+async function handleCancel(row: AftersalesTicketApi.Ticket) {
+ await cancelTicket(row.id!);
+ message.success('鍙栨秷鎴愬姛');
+ handleRefresh();
+}
+
+/** 鍒犻櫎宸ュ崟 */
+async function handleDelete(row: AftersalesTicketApi.Ticket) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.no]),
+ duration: 0,
+ });
+ try {
+ await deleteTicket(row.id!);
+ message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useTicketGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useTicketGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getTicketPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ sceneType: sceneType.value,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<AftersalesTicketApi.Ticket>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+
+ <Grid table-title="鍞悗宸ュ崟鍒楄〃">
+ <template #toolbar-actions>
+ <Tabs class="w-full" @change="handleChangeSceneType">
+ <Tabs.TabPane tab="鎴戣礋璐g殑" key="1" />
+ <Tabs.TabPane tab="鎴戝弬涓庣殑" key="2" />
+ <Tabs.TabPane tab="涓嬪睘璐熻矗鐨�" key="3" />
+ <Tabs.TabPane tab="鍏ㄩ儴" key="4" />
+ </Tabs>
+ </template>
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: $t('ui.actionTitle.create', ['鍞悗宸ュ崟']),
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['aftersales:ticket:create'],
+ onClick: handleCreate,
+ },
+ ]"
+ />
+ </template>
+
+ <template #no="{ row }">
+ <Button type="link" @click="handleDetail(row)">
+ {{ row.no }}
+ </Button>
+ </template>
+
+ <template #issueType="{ row }">
+ <Tag v-if="row.issueType === 1" color="blue">涓�鑸棶棰�</Tag>
+ <Tag v-else-if="row.issueType === 2" color="orange">缁翠慨闂</Tag>
+ <Tag v-else-if="row.issueType === 3" color="red">閫�璐ч棶棰�</Tag>
+ </template>
+
+ <template #severityLevel="{ row }">
+ <Tag v-if="row.severityLevel === 0" color="green">杞诲井</Tag>
+ <Tag v-else-if="row.severityLevel === 1" color="blue">涓�鑸�</Tag>
+ <Tag v-else-if="row.severityLevel === 2" color="orange">涓ラ噸</Tag>
+ <Tag v-else-if="row.severityLevel === 3" color="red">鑷村懡</Tag>
+ </template>
+
+ <template #status="{ row }">
+ <Tag v-if="row.status === 0">鑽夌</Tag>
+ <Tag v-else-if="row.status === 10" color="processing">澶勭悊涓�</Tag>
+ <Tag v-else-if="row.status === 30" color="warning">寰呴��璐�</Tag>
+ <Tag v-else-if="row.status === 40" color="warning">寰呴��娆�/鎹㈣揣</Tag>
+ <Tag v-else-if="row.status === 50" color="success">宸插畬缁�</Tag>
+ <Tag v-else-if="row.status === 60">宸插叧闂�</Tag>
+ <Tag v-else-if="row.status === 99" color="error">宸插彇娑�</Tag>
+ </template>
+
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '鏌ョ湅',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ onClick: handleDetail.bind(null, row),
+ },
+ {
+ label: $t('common.edit'),
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['aftersales:ticket:update'],
+ ifShow: row.status === AftersaleTicketStatusEnum.DRAFT,
+ onClick: handleEdit.bind(null, row),
+ },
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['aftersales:ticket:delete'],
+ ifShow:
+ row.status === AftersaleTicketStatusEnum.DRAFT ||
+ row.status === AftersaleTicketStatusEnum.CANCELLED,
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.no]),
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ {
+ label: '鍙栨秷',
+ type: 'link',
+ danger: true,
+ auth: ['aftersales:ticket:update'],
+ ifShow:
+ row.status === AftersaleTicketStatusEnum.DRAFT ||
+ row.status === AftersaleTicketStatusEnum.PROCESSING,
+ popConfirm: {
+ title: '纭鍙栨秷璇ュ伐鍗曪紵鍙栨秷鍚庝笉鍙仮澶嶃��',
+ confirm: handleCancel.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/aftersales/ticket/modules/form.vue b/src/views/aftersales/ticket/modules/form.vue
new file mode 100644
index 0000000..3a4ba1c
--- /dev/null
+++ b/src/views/aftersales/ticket/modules/form.vue
@@ -0,0 +1,318 @@
+<script lang="ts" setup>
+import type { TicketFormType } from '../data';
+
+import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
+import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { computed, nextTick, ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { useVbenModal } from '@vben/common-ui';
+import { AftersaleIssueTypeEnum, AftersaleTicketStatusEnum } from '@vben/constants';
+
+import { Button, Divider, Input, message, Popconfirm, Select, Tag } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ cancelTicket,
+ classifyTicket,
+ closeTicket,
+ createTicket,
+ getTicket,
+ resolveTicket,
+ updateTicket,
+} from '#/api/aftersales/ticket';
+import { getSaleOrder } from '#/api/erp/sale/order';
+import { $t } from '#/locales';
+
+import { useTicketFormSchema, useTicketItemFormSchema, useTicketItemGridColumns } from '../data';
+
+defineOptions({ name: 'TicketForm' });
+
+const emit = defineEmits(['success']);
+const router = useRouter();
+
+const TS = AftersaleTicketStatusEnum;
+const IT = AftersaleIssueTypeEnum;
+
+const formType = ref<TicketFormType>('create');
+const formData = ref<AftersalesTicketApi.Ticket>();
+const items = ref<AftersalesTicketApi.TicketItem[]>([]);
+const editingItemIndex = ref<number | null>(null);
+const submitting = ref(false);
+
+// 闂鍒ゅ畾
+const showClassify = ref(false);
+const classifyIssueType = ref<number>();
+const classifyCategory = ref('');
+const classifySeverity = ref<number>();
+
+const issueTypeOpts = [
+ { label: '涓�鑸棶棰�', value: IT.GENERAL },
+ { label: '缁翠慨闂', value: IT.REPAIR },
+ { label: '閫�璐ч棶棰�', value: IT.RETURN },
+];
+const severityOpts = [
+ { label: '杞诲井', value: 0 },
+ { label: '涓�鑸�', value: 1 },
+ { label: '涓ラ噸', value: 2 },
+ { label: '鑷村懡', value: 3 },
+];
+
+const isEdit = computed(() => ['create', 'update'].includes(formType.value));
+const isDraft = computed(() => formData.value?.status === TS.DRAFT);
+const isProcessing = computed(() => formData.value?.status === TS.PROCESSING);
+const isPendingReturn = computed(() => formData.value?.status === TS.PENDING_RETURN);
+const isPendingRefund = computed(() => formData.value?.status === TS.PENDING_REFUND);
+const isResolved = computed(() => formData.value?.status === TS.RESOLVED);
+const isReturnIssue = computed(() => formData.value?.issueType === IT.RETURN);
+
+const title = computed(() => {
+ if (formType.value === 'detail') return $t('ui.actionTitle.view', ['鍞悗宸ュ崟']);
+ if (formData.value?.id) return $t('ui.actionTitle.edit', ['鍞悗宸ュ崟']);
+ return $t('ui.actionTitle.create', ['鍞悗宸ュ崟']);
+});
+
+// 閿�鍞鍗曠墿鏂欒繃婊�
+const orderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]);
+
+function handleSaleOrderChange(item: Record<string, unknown> | undefined) {
+ if (!item?.id) {
+ orderItems.value = [];
+ return;
+ }
+ orderItems.value = (item.items as ErpSaleOrderApi.SaleOrderItem[]) || [];
+}
+
+// 涓昏〃鍗�
+const [Form, formApi] = useVbenForm({
+ commonConfig: { componentProps: { class: 'w-full' }, formItemClass: 'col-span-1', labelWidth: 110 },
+ layout: 'horizontal', schema: [], showDefaultActions: false, wrapperClass: 'grid-cols-3',
+});
+
+// 鏄庣粏琛岃〃鍗�
+const [ItemForm, itemFormApi] = useVbenForm({
+ commonConfig: { componentProps: { class: 'w-full' }, formItemClass: 'col-span-1', labelWidth: 100 },
+ layout: 'horizontal', schema: [], showDefaultActions: false, wrapperClass: 'grid-cols-3',
+});
+
+// 鏄庣粏琛岃〃鏍�
+const [ItemsGrid, itemsGridApi] = useVbenVxeGrid({
+ gridOptions: {
+ columns: useTicketItemGridColumns(true),
+ height: 300,
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async () => {
+ return {
+ list: items.value,
+ total: items.value.length,
+ };
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ pagerConfig: { enabled: false },
+ toolbarConfig: { enabled: false },
+ } as VxeTableGridOptions<AftersalesTicketApi.TicketItem>,
+});
+
+async function refreshItemsGrid() {
+ await nextTick();
+ itemsGridApi.query();
+}
+
+// Modal锛堝伐鍗曡〃鍗曞脊妗嗭級蹇呴』鍦� ItemModal 涔嬪墠锛岀‘淇� connectedComponent 娉ㄥ叆鍒版纭殑寮规
+const [Modal, modalApi] = useVbenModal({
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) { formData.value = undefined; items.value = []; orderItems.value = []; showClassify.value = false; return; }
+ const data = modalApi.getData<{ formType: TicketFormType; id?: number }>();
+ formType.value = data.formType;
+ itemsGridApi.setGridOptions({ columns: useTicketItemGridColumns(isEdit.value) });
+ formApi.setState({ schema: useTicketFormSchema(formType.value, formApi, handleSaleOrderChange) });
+ formApi.setDisabled(!isEdit.value);
+ modalApi.setState({ showConfirmButton: false, showCancelButton: false });
+ if (data?.id) {
+ modalApi.lock();
+ try {
+ formData.value = await getTicket(data.id);
+ items.value = formData.value?.items || [];
+ await refreshItemsGrid();
+ await formApi.setValues(formData.value as Record<string, unknown>);
+ if (formData.value?.saleOrderId) {
+ const order = await getSaleOrder(formData.value.saleOrderId);
+ orderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || [];
+ }
+ } finally { modalApi.unlock(); }
+ } else {
+ formData.value = undefined;
+ items.value = [];
+ await refreshItemsGrid();
+ await formApi.resetForm();
+ }
+ },
+});
+
+const [ItemModal, itemModalApi] = useVbenModal({
+ onConfirm: async () => {
+ const { valid } = await itemFormApi.validate();
+ if (!valid) return;
+ const d = (await itemFormApi.getValues()) as unknown as AftersalesTicketApi.TicketItem;
+ if (editingItemIndex.value !== null) {
+ items.value[editingItemIndex.value] = { ...items.value[editingItemIndex.value], ...d };
+ } else {
+ items.value.push({ ...d });
+ }
+ await refreshItemsGrid();
+ itemModalApi.close();
+ },
+ onOpenChange(isOpen: boolean) {
+ if (!isOpen) { editingItemIndex.value = null; return; }
+ const d = itemModalApi.getData<AftersalesTicketApi.TicketItem | null>();
+ itemFormApi.setState({ schema: useTicketItemFormSchema(formData.value?.customerId, formData.value?.saleOrderNo, orderItems.value, itemFormApi) });
+ if (d) { itemFormApi.setValues(d as Record<string, unknown>); } else { itemFormApi.resetForm(); }
+ },
+});
+
+function addItem() { editingItemIndex.value = null; itemModalApi.setData(null).open(); }
+function editItem(i: number) { editingItemIndex.value = i; itemModalApi.setData(items.value[i]).open(); }
+async function delItem(i: number) { items.value.splice(i, 1); await refreshItemsGrid(); }
+
+// === 鎻愪氦 ===
+async function handleSubmit() {
+ const { valid } = await formApi.validate();
+ if (!valid) return;
+ submitting.value = true;
+ try {
+ const data = (await formApi.getValues()) as Record<string, unknown>;
+ if (formData.value?.id) {
+ await updateTicket({ ...formData.value, ...data, items: items.value } as AftersalesTicketApi.Ticket);
+ } else {
+ await createTicket({ ...data, items: items.value } as AftersalesTicketApi.Ticket);
+ }
+ message.success($t('ui.actionMessage.operationSuccess'));
+ await modalApi.close();
+ emit('success');
+ } catch (e: unknown) {
+ message.error((e as Error).message || '鎻愪氦澶辫触');
+ } finally { submitting.value = false; }
+}
+
+function toggleClassify() {
+ showClassify.value = !showClassify.value;
+ if (showClassify.value) {
+ classifyIssueType.value = formData.value?.issueType;
+ classifyCategory.value = formData.value?.issueCategory || '';
+ classifySeverity.value = formData.value?.severityLevel;
+ }
+}
+async function doClassify() {
+ if (!classifyIssueType.value || !formData.value?.id) return message.warning('璇烽�夋嫨闂绫诲瀷');
+ submitting.value = true;
+ try {
+ await classifyTicket({ id: formData.value.id, issueType: classifyIssueType.value, issueCategory: classifyCategory.value || undefined, severityLevel: classifySeverity.value });
+ message.success('鍒ゅ畾鎴愬姛'); showClassify.value = false;
+ formData.value = await getTicket(formData.value.id);
+ await formApi.setValues(formData.value as Record<string, unknown>);
+ } finally { submitting.value = false; }
+}
+async function doResolve() {
+ if (!formData.value?.id) return;
+ submitting.value = true;
+ try { await resolveTicket(formData.value.id); message.success('瀹岀粨鎴愬姛'); await modalApi.close(); emit('success'); }
+ finally { submitting.value = false; }
+}
+async function doClose() {
+ if (!formData.value?.id) return;
+ submitting.value = true;
+ try { await closeTicket(formData.value.id); message.success('鍏抽棴鎴愬姛'); await modalApi.close(); emit('success'); }
+ finally { submitting.value = false; }
+}
+async function doCancel() {
+ if (!formData.value?.id) return;
+ submitting.value = true;
+ try { await cancelTicket(formData.value.id); message.success('鍙栨秷鎴愬姛'); await modalApi.close(); emit('success'); }
+ finally { submitting.value = false; }
+}
+function toCreateReturn() {
+ if (!formData.value?.id) return;
+ router.push({ name: 'AftersalesReturnDetail', params: { id: 'new' }, query: { ticketId: formData.value.id } });
+ modalApi.close();
+}
+</script>
+
+<template>
+ <Modal :title="title" class="w-4/5">
+ <Form class="mx-4" />
+
+ <!-- 鏄庣粏琛� -->
+ <template v-if="isEdit || formData?.id">
+ <Divider>宸ュ崟鏄庣粏</Divider>
+ <div class="mx-4">
+ <div v-if="isEdit" class="mb-2"><Button type="primary" @click="addItem">娣诲姞鐗╂枡</Button></div>
+ <ItemsGrid>
+ <template #needRepair="{ row }"><Tag :color="row.needRepair ? 'blue' : 'default'">{{ row.needRepair ? '鏄�' : '鍚�' }}</Tag></template>
+ <template #needReturn="{ row }"><Tag :color="row.needReturn ? 'orange' : 'default'">{{ row.needReturn ? '鏄�' : '鍚�' }}</Tag></template>
+ <template v-if="isEdit" #itemActions="{ row, rowIndex }">
+ <TableAction :actions="[
+ { label: $t('common.edit'), type: 'link', icon: ACTION_ICON.EDIT, onClick: () => editItem(rowIndex) },
+ { label: $t('common.delete'), type: 'link', danger: true, icon: ACTION_ICON.DELETE, popConfirm: { title: $t('ui.actionMessage.deleteConfirm', [row.itemName||'']), confirm: () => delItem(rowIndex) } },
+ ]" />
+ </template>
+ </ItemsGrid>
+ </div>
+ </template>
+
+ <!-- 闂鍒ゅ畾 -->
+ <template v-if="showClassify">
+ <Divider>闂鍒ゅ畾</Divider>
+ <div class="mx-4 flex flex-wrap items-center gap-3">
+ <span>闂绫诲瀷锛�</span><Select v-model:value="classifyIssueType" :options="issueTypeOpts" style="width:140px" />
+ <span>闂鍒嗙被锛�</span><Input v-model:value="classifyCategory" style="width:160px" />
+ <span>涓ラ噸绋嬪害锛�</span><Select v-model:value="classifySeverity" :options="severityOpts" style="width:120px" />
+ <Button type="primary" size="small" :loading="submitting" @click="doClassify">纭鍒ゅ畾</Button>
+ <Button size="small" @click="toggleClassify">鍙栨秷</Button>
+ </div>
+ </template>
+
+ <!-- 搴曢儴鎸夐挳 -->
+ <template #prepend-footer>
+ <div v-if="isEdit" class="flex flex-auto items-center gap-2">
+ <!-- 闂鍒ゅ畾: 澶勭悊涓� -->
+ <Button v-if="isProcessing && !showClassify" @click="toggleClassify">闂鍒ゅ畾</Button>
+ <!-- 鍒涘缓閫�璐�: 澶勭悊涓� + 閫�璐ч棶棰� -->
+ <Button v-if="isProcessing && isReturnIssue" type="primary" @click="toCreateReturn">鍒涘缓閫�璐х敵璇�</Button>
+ <!-- 瀹岀粨: 澶勭悊涓�/寰呴��璐�/寰呴��娆� -->
+ <template v-if="isProcessing || isPendingReturn || isPendingRefund">
+ <Popconfirm title="纭瀹岀粨璇ュ伐鍗曪紵" @confirm="doResolve"><Button type="primary" :loading="submitting">瀹岀粨</Button></Popconfirm>
+ </template>
+ <!-- 鍏抽棴: 宸插畬缁� -->
+ <template v-if="isResolved">
+ <Popconfirm title="纭鍏抽棴璇ュ伐鍗曪紵鍏抽棴鍚庝笉鍙仮澶嶃��" @confirm="doClose"><Button type="primary" :loading="submitting">鍏抽棴</Button></Popconfirm>
+ </template>
+ <!-- 鍙栨秷: 鑽夌/澶勭悊涓� -->
+ <template v-if="isDraft || isProcessing">
+ <Popconfirm title="纭鍙栨秷璇ュ伐鍗曪紵鍙栨秷鍚庝笉鍙仮澶嶃��" @confirm="doCancel"><Button danger :loading="submitting">鍙栨秷宸ュ崟</Button></Popconfirm>
+ </template>
+ </div>
+ </template>
+
+ <!-- 鎻愪氦/鍙栨秷鎸夐挳锛堢紪杈戞ā寮忥級 -->
+ <template #footer>
+ <div v-if="isEdit" class="flex justify-end gap-2">
+ <Button @click="modalApi.close()">{{ $t('common.cancel') }}</Button>
+ <Button type="primary" :loading="submitting" @click="handleSubmit">{{ $t('common.confirm') }}</Button>
+ </div>
+ </template>
+ </Modal>
+
+ <ItemModal :title="editingItemIndex !== null ? '缂栬緫鐗╂枡' : '娣诲姞鐗╂枡'" class="w-1/2">
+ <ItemForm class="mx-4" />
+ </ItemModal>
+</template>
diff --git a/src/views/srm/tender/detail.vue b/src/views/srm/tender/detail.vue
index 392af47..e893f65 100644
--- a/src/views/srm/tender/detail.vue
+++ b/src/views/srm/tender/detail.vue
@@ -255,6 +255,7 @@
const statusLabel = computed(() => getLabel(TENDER_STATUS_CELLTAG.labels, project.value?.tenderStatus));
const statusColor = computed(() => TENDER_STATUS_COLORS[project.value?.tenderStatus!] || 'default');
const typeLabel = computed(() => getLabel(TENDER_TYPE_CELLTAG.labels, project.value?.tenderType));
+const isAwarded = computed(() => project.value?.tenderStatus === TENDER_STATUS.AWARDED);
const stepItems = [
{ title: '鑽夌', status: TENDER_STATUS.DRAFT },
@@ -421,7 +422,7 @@
<a-space>
<span class="text-xs text-gray-400">鍏� <b class="text-gray-600">{{ materialList.length }}</b> 椤癸紝棰勪及鎬婚噾棰� <b class="text-blue-500">{{ materialTotalAmount.toLocaleString() }}</b> 鍏�</span>
</a-space>
- <a-button type="primary" size="small" @click="handleAddMaterial">
+ <a-button v-if="!isAwarded" type="primary" size="small" @click="handleAddMaterial">
<template #icon><IconifyIcon icon="ant-design:plus-outlined" /></template>
娣诲姞鐗╂枡
</a-button>
@@ -432,7 +433,7 @@
{{ record.estimatedPrice ? record.estimatedPrice.toLocaleString() : '-' }}
</template>
<template v-if="column.key === 'actions'">
- <a-space>
+ <a-space v-if="!isAwarded">
<a-button size="small" type="link" @click="handleEditMaterial(record)"><IconifyIcon icon="ant-design:edit-outlined" />缂栬緫</a-button>
<a-popconfirm title="纭鍒犻櫎璇ョ墿鏂欙紵" @confirm="handleDeleteMaterial(record.id)">
<a-button size="small" type="link" danger><IconifyIcon icon="ant-design:delete-outlined" />鍒犻櫎</a-button>
--
Gitblit v1.9.3