| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /** å®åå·¥åç¶ææä¸¾ */ |
| | | 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; |
| | |
| | | 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 审æ¹ç¶æ |
| | |
| | | |
| | | /** åå
¸ç±»åæä¸¾ - ç»ä¸å¯¼åº */ |
| | | const DICT_TYPE = { |
| | | ...AFTERSALES_DICT, |
| | | ...AI_DICT, |
| | | ...BPM_DICT, |
| | | ...CRM_DICT, |
| | |
| | | export * from './biz-aftersales-enum'; |
| | | export * from './biz-ai-enum'; |
| | | export * from './biz-bpm-enum'; |
| | | export * from './biz-erp-enum'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | export { default as AftersalesTicketSelect } from './select.vue'; |
| | | export { default as AftersalesTicketSelectDialog } from './select-dialog.vue'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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, |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | 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 }, |
| | |
| | | <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> |
| | |
| | | {{ 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> |