gaoluyang
14 小时以前 09fe6d73f812a6445c53b240c42ba13b7d568123
银川
1.售后管理模块开发联调
已添加15个文件
已修改3个文件
2905 ■■■■■ 文件已修改
src/api/aftersales/return.ts 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/ticket.ts 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-aftersales-enum.ts 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/aftersales.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/return/data.ts 318 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/return/detail.vue 356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/return/index.vue 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/return/modules/form.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/components/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/components/select-dialog.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/components/select.vue 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/data.ts 489 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/detail.vue 331 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/index.vue 212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/modules/form.vue 318 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/detail.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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 },
  );
}
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 },
  });
}
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, // å®¡æ ¸é€šè¿‡-已同步MES
  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;
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,
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';
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;
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: '销售订单ID',
      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,
    },
  ];
}
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: '已同步MES' },
  [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>
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: `正在审核退货单 [${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">已同步MES</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>
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>
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';
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>
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>
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,
      },
    },
  ];
}
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="处理人ID"
              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>
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="我负责的" 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>
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>
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>