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