feat(aftersales): 工单回访/归档 + 问题库页面
- 工单详情新增「回访状态」「回访记录(登记回访)」与「归档」按钮(回访通过后才显示)
- 工单列表点单号进入路由详情页(可回访/归档)
- 新增问题库页面(列表/新增/编辑/删除/发布/停用)及 visit/problem API
- 修正详情页 Descriptions 列 span 布局告警
Co-Authored-By: Claude <noreply@anthropic.com>
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace AftersalesProblemApi { |
| | | /** å®åé®é¢ */ |
| | | export interface Problem { |
| | | id?: number; |
| | | no?: string; |
| | | title?: string; |
| | | sourceTicketId?: number; |
| | | sourceTicketNo?: string; |
| | | customerId?: number; |
| | | customerName?: string; |
| | | itemId?: number; |
| | | itemCode?: string; |
| | | itemName?: string; |
| | | batchId?: number; |
| | | batchCode?: string; |
| | | issueType?: number; |
| | | issueCategory?: string; |
| | | severityLevel?: number; |
| | | description?: string; |
| | | /** æ ¹å åç±»: 1-设计 2-ç©æ 3-å·¥èº 4-è£
é
5-è¿è¾ 6-å
è£
7-ä½¿ç¨ 8-软件 9-å
¶ä» */ |
| | | rootCauseCategory?: number; |
| | | rootCause?: string; |
| | | solution?: string; |
| | | /** ç¶æ: 0-è稿 1-å·²åå¸ 2-å·²åç¨ */ |
| | | status?: number; |
| | | ownerUserId?: number; |
| | | ownerUserName?: string; |
| | | publishTime?: string; |
| | | remark?: string; |
| | | creator?: string; |
| | | createTime?: string; |
| | | updater?: string; |
| | | updateTime?: string; |
| | | /** éä»¶ blobId å表ï¼ä¿åæ¶ä¼ å
¥ï¼ */ |
| | | blobIds?: number[]; |
| | | /** éä»¶å表ï¼è¯¦æ
è¿åï¼ */ |
| | | attachmentList?: { id: number; name?: string; url?: string }[]; |
| | | } |
| | | } |
| | | |
| | | /** æ°å¢å®åé®é¢ï¼èç¨¿ï¼ */ |
| | | export function createProblem(data: AftersalesProblemApi.Problem) { |
| | | return requestClient.post<number>('/aftersales/problem/create', data); |
| | | } |
| | | |
| | | /** æ´æ°å®åé®é¢ï¼ä»
èç¨¿ï¼ */ |
| | | export function updateProblem(data: AftersalesProblemApi.Problem) { |
| | | return requestClient.put<boolean>('/aftersales/problem/update', data); |
| | | } |
| | | |
| | | /** å é¤å®åé®é¢ï¼ä»
èç¨¿ï¼ */ |
| | | export function deleteProblem(id: number) { |
| | | return requestClient.delete<boolean>('/aftersales/problem/delete', { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** è·åå®åé®é¢è¯¦æ
*/ |
| | | export function getProblem(id: number) { |
| | | return requestClient.get<AftersalesProblemApi.Problem>( |
| | | `/aftersales/problem/get?id=${id}`, |
| | | ); |
| | | } |
| | | |
| | | /** å页æ¥è¯¢å®åé®é¢ */ |
| | | export function getProblemPage(params: PageParam) { |
| | | return requestClient.get<PageResult<AftersalesProblemApi.Problem>>( |
| | | '/aftersales/problem/page', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** åå¸é®é¢ï¼è稿/åç¨ -> å·²åå¸ï¼ */ |
| | | export function publishProblem(id: number) { |
| | | return requestClient.put<boolean>('/aftersales/problem/publish', null, { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** åç¨é®é¢ï¼å·²åå¸ -> åç¨ï¼ */ |
| | | export function disableProblem(id: number) { |
| | | return requestClient.put<boolean>('/aftersales/problem/disable', null, { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** åèæ£ç´¢ï¼æ°å·¥ååçï¼åªè¿åå·²åå¸ï¼ */ |
| | | export function referenceProblem(params: { |
| | | itemId?: number; |
| | | issueCategory?: string; |
| | | issueType?: number; |
| | | rootCauseCategory?: number; |
| | | keyword?: string; |
| | | }) { |
| | | return requestClient.get<AftersalesProblemApi.Problem[]>( |
| | | '/aftersales/problem/reference', |
| | | { params }, |
| | | ); |
| | | } |
| | |
| | | repairId?: number; |
| | | resolvedTime?: string; |
| | | closedTime?: string; |
| | | /** åè®¿ç¶æ: 0-å¾
å访 1-å·²å访éè¿ */ |
| | | visitStatus?: number; |
| | | lastVisitTime?: string; |
| | | lastVisitSatisfied?: boolean; |
| | | lastVisitRecurred?: boolean; |
| | | /** 彿¡£æ²æ·çé®é¢ */ |
| | | problemId?: number; |
| | | problemNo?: string; |
| | | remark?: string; |
| | | creator?: string; |
| | | createTime?: string; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace AftersalesVisitApi { |
| | | /** åè®¿è®°å½ */ |
| | | export interface Visit { |
| | | id?: number; |
| | | ticketId?: number; |
| | | visitType?: number; |
| | | visitorUserId?: number; |
| | | visitorUserName?: string; |
| | | content?: string; |
| | | satisfied?: boolean; |
| | | recurred?: boolean; |
| | | visitTime?: string; |
| | | remark?: string; |
| | | creator?: string; |
| | | createTime?: string; |
| | | /** éä»¶å表ï¼è¯¦æ
è¿åï¼ */ |
| | | attachmentList?: { id: number; name?: string; url?: string }[]; |
| | | } |
| | | |
| | | /** ç»è®°å访å
¥å */ |
| | | export interface CreateVisit { |
| | | ticketId: number; |
| | | visitType: number; |
| | | content?: string; |
| | | satisfied: boolean; |
| | | recurred: boolean; |
| | | remark?: string; |
| | | /** éä»¶ blobId å表ï¼ä¿åæ¶ä¼ å
¥ï¼ */ |
| | | blobIds?: number[]; |
| | | } |
| | | } |
| | | |
| | | /** ç»è®°å®åå访 */ |
| | | export function createVisit(data: AftersalesVisitApi.CreateVisit) { |
| | | return requestClient.post<number>('/aftersales/visit/create', data); |
| | | } |
| | | |
| | | /** è·åå访详æ
*/ |
| | | export function getVisit(id: number) { |
| | | return requestClient.get<AftersalesVisitApi.Visit>( |
| | | `/aftersales/visit/get?id=${id}`, |
| | | ); |
| | | } |
| | | |
| | | /** è·åæå·¥åçå访记å½å表 */ |
| | | export function getVisitListByTicketId(ticketId: number) { |
| | | return requestClient.get<AftersalesVisitApi.Visit[]>( |
| | | `/aftersales/visit/list?ticketId=${ticketId}`, |
| | | ); |
| | | } |
| | | |
| | | /** å页æ¥è¯¢åè®¿è®°å½ */ |
| | | export function getVisitPage(params: PageParam) { |
| | | return requestClient.get<PageResult<AftersalesVisitApi.Visit>>( |
| | | '/aftersales/visit/page', |
| | | { params }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { AftersalesProblemApi } from '#/api/aftersales/problem'; |
| | | |
| | | /** 表åç±»å */ |
| | | export type ProblemFormType = 'create' | 'update' | 'detail'; |
| | | |
| | | /** é®é¢ç±»åé项ï¼å¯¹é½ AfterSaleIssueTypeEnumï¼ */ |
| | | export const ISSUE_TYPE_OPTIONS = [ |
| | | { label: 'ä¸è¬é®é¢', value: 1 }, |
| | | { label: 'ç»´ä¿®é®é¢', value: 2 }, |
| | | { label: 'éè´§é®é¢', value: 3 }, |
| | | ]; |
| | | |
| | | /** 严éç¨åº¦é项 */ |
| | | export const SEVERITY_LEVEL_OPTIONS = [ |
| | | { label: '轻微', value: 0 }, |
| | | { label: 'ä¸è¬', value: 1 }, |
| | | { label: '严é', value: 2 }, |
| | | { label: 'è´å½', value: 3 }, |
| | | ]; |
| | | |
| | | /** æ ¹å åç±»é项ï¼å¯¹é½ AfterSaleRootCauseCategoryEnumï¼ */ |
| | | export const ROOT_CAUSE_CATEGORY_OPTIONS = [ |
| | | { label: '设计缺é·', value: 1 }, |
| | | { label: 'ç©æç¼ºé·', value: 2 }, |
| | | { label: 'å·¥èºç¼ºé·', value: 3 }, |
| | | { label: 'è£
é
缺é·', value: 4 }, |
| | | { label: 'è¿è¾æå', value: 5 }, |
| | | { label: 'å
è£
缺é·', value: 6 }, |
| | | { label: '使ç¨ä¸å½', value: 7 }, |
| | | { label: '软件缺é·', value: 8 }, |
| | | { label: 'å
¶ä»', value: 9 }, |
| | | ]; |
| | | |
| | | /** é®é¢ç¶æé项ï¼å¯¹é½ AfterSaleProblemStatusEnumï¼ */ |
| | | export const PROBLEM_STATUS_OPTIONS = [ |
| | | { label: 'è稿', value: 0 }, |
| | | { label: 'å·²åå¸', value: 1 }, |
| | | { label: 'å·²åç¨', value: 2 }, |
| | | ]; |
| | | |
| | | /** é®é¢åºè¡¨å */ |
| | | export function useProblemFormSchema( |
| | | formType: ProblemFormType, |
| | | ): VbenFormSchema[] { |
| | | const readonly = formType === 'detail'; |
| | | return [ |
| | | { |
| | | fieldName: 'id', |
| | | component: 'Input', |
| | | dependencies: { triggerFields: [''], show: () => false }, |
| | | }, |
| | | { |
| | | fieldName: 'title', |
| | | label: 'é®é¢æ é¢', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请è¾å
¥é®é¢æ é¢' }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'issueType', |
| | | label: 'é®é¢ç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | options: ISSUE_TYPE_OPTIONS, |
| | | placeholder: 'è¯·éæ©é®é¢ç±»å', |
| | | disabled: readonly, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'issueCategory', |
| | | label: 'é®é¢åç±»', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: 'å¦åè½æ
éãå¤è§ç¼ºé·', |
| | | disabled: readonly, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'severityLevel', |
| | | label: '严éç¨åº¦', |
| | | component: 'Select', |
| | | componentProps: { |
| | | options: SEVERITY_LEVEL_OPTIONS, |
| | | placeholder: 'è¯·éæ©', |
| | | disabled: readonly, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'rootCauseCategory', |
| | | label: 'æ ¹å åç±»', |
| | | component: 'Select', |
| | | componentProps: { |
| | | options: ROOT_CAUSE_CATEGORY_OPTIONS, |
| | | placeholder: 'è¯·éæ©æ ¹å åç±»', |
| | | disabled: readonly, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'itemCode', |
| | | label: 'ç©æç¼ç ', |
| | | component: 'Input', |
| | | componentProps: { placeholder: 'æ¥æºäºå·¥åæç»', disabled: readonly }, |
| | | }, |
| | | { |
| | | fieldName: 'itemName', |
| | | label: 'ç©æåç§°', |
| | | component: 'Input', |
| | | componentProps: { placeholder: 'æ¥æºäºå·¥åæç»', disabled: readonly }, |
| | | }, |
| | | { |
| | | fieldName: 'batchCode', |
| | | label: 'æ¹æ¬¡å·', |
| | | component: 'Input', |
| | | componentProps: { placeholder: 'æ¥æºäºå·¥åæç»', disabled: readonly }, |
| | | }, |
| | | { |
| | | fieldName: 'description', |
| | | label: 'é®é¢æè¿°', |
| | | component: 'Textarea', |
| | | formItemClass: 'col-span-2', |
| | | componentProps: { |
| | | rows: 3, |
| | | placeholder: 'é®é¢ç°è±¡ä¸èæ¯', |
| | | disabled: readonly, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'rootCause', |
| | | label: 'æ ¹å åæ', |
| | | component: 'Textarea', |
| | | formItemClass: 'col-span-2', |
| | | componentProps: { |
| | | rows: 3, |
| | | placeholder: '缺é·äº§ççæ ¹æ¬åå ', |
| | | disabled: readonly, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'solution', |
| | | label: 'è§£å³æ¹æ¡', |
| | | component: 'Textarea', |
| | | formItemClass: 'col-span-2', |
| | | componentProps: { |
| | | rows: 3, |
| | | placeholder: 'å¤çæªæ½/SOPï¼ä¾åç»å·¥ååè', |
| | | disabled: readonly, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'remark', |
| | | label: '夿³¨', |
| | | component: 'Textarea', |
| | | formItemClass: 'col-span-2', |
| | | componentProps: { rows: 2, placeholder: '夿³¨', disabled: readonly }, |
| | | }, |
| | | { |
| | | fieldName: 'blobIds', |
| | | label: 'éä»¶', |
| | | component: 'FileUpload', |
| | | componentProps: { |
| | | valueKey: 'id', |
| | | maxNumber: 10, |
| | | multiple: true, |
| | | disabled: readonly, |
| | | }, |
| | | formItemClass: 'col-span-2', |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** é®é¢åºæç´¢è¡¨å */ |
| | | export function useProblemSearchFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'keyword', |
| | | label: 'å
³é®å', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: 'åå·/æ é¢/æè¿°/æ¹æ¡', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'itemCode', |
| | | label: 'ç©æç¼ç ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥ç©æç¼ç ', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'issueCategory', |
| | | label: 'é®é¢åç±»', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥é®é¢åç±»', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'issueType', |
| | | label: 'é®é¢ç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: ISSUE_TYPE_OPTIONS, |
| | | placeholder: 'è¯·éæ©', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'rootCauseCategory', |
| | | label: 'æ ¹å åç±»', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: ROOT_CAUSE_CATEGORY_OPTIONS, |
| | | placeholder: 'è¯·éæ©', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'status', |
| | | label: 'ç¶æ', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: PROBLEM_STATUS_OPTIONS, |
| | | placeholder: 'è¯·éæ©ç¶æ', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** é®é¢åºåè¡¨åæ®µ */ |
| | | export function useProblemGridColumns(): VxeTableGridOptions<AftersalesProblemApi.Problem>['columns'] { |
| | | return [ |
| | | { field: 'no', title: 'é®é¢åå·', minWidth: 170, slots: { default: 'no' } }, |
| | | { field: 'title', title: 'é®é¢æ é¢', minWidth: 180 }, |
| | | { field: 'sourceTicketNo', title: 'æ¥æºå·¥å', minWidth: 150 }, |
| | | { field: 'itemName', title: 'ç©æåç§°', minWidth: 120 }, |
| | | { field: 'itemCode', title: 'ç©æç¼ç ', minWidth: 120 }, |
| | | { field: 'issueType', title: 'é®é¢ç±»å', width: 100, slots: { default: 'issueType' } }, |
| | | { field: 'severityLevel', title: '严éç¨åº¦', width: 100, slots: { default: 'severityLevel' } }, |
| | | { field: 'rootCauseCategory', title: 'æ ¹å åç±»', width: 100, slots: { default: 'rootCauseCategory' } }, |
| | | { field: 'status', title: 'ç¶æ', width: 90, slots: { default: 'status' } }, |
| | | { field: 'ownerUserName', title: 'æ²æ·äºº', minWidth: 100 }, |
| | | { field: 'updateTime', title: 'æ´æ°æ¶é´', width: 170, formatter: 'formatDate' }, |
| | | { title: 'æä½', width: 220, fixed: 'right', slots: { default: 'actions' } }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { AftersalesProblemApi } from '#/api/aftersales/problem'; |
| | | |
| | | 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 { |
| | | deleteProblem, |
| | | disableProblem, |
| | | getProblemPage, |
| | | publishProblem, |
| | | } from '#/api/aftersales/problem'; |
| | | |
| | | import { |
| | | PROBLEM_STATUS_OPTIONS, |
| | | ROOT_CAUSE_CATEGORY_OPTIONS, |
| | | useProblemGridColumns, |
| | | useProblemSearchFormSchema, |
| | | } from './data'; |
| | | import ProblemForm from './modules/form.vue'; |
| | | |
| | | const statusLabelMap = Object.fromEntries( |
| | | PROBLEM_STATUS_OPTIONS.map((o) => [o.value, o.label]), |
| | | ); |
| | | const rootCauseLabelMap = Object.fromEntries( |
| | | ROOT_CAUSE_CATEGORY_OPTIONS.map((o) => [o.value, o.label]), |
| | | ); |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | | connectedComponent: ProblemForm, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | function handleRefresh() { |
| | | gridApi.query(); |
| | | } |
| | | |
| | | function handleCreate() { |
| | | formModalApi.setData({ formType: 'create' }).open(); |
| | | } |
| | | |
| | | function handleDetail(row: AftersalesProblemApi.Problem) { |
| | | formModalApi.setData({ formType: 'detail', id: row.id }).open(); |
| | | } |
| | | |
| | | function handleEdit(row: AftersalesProblemApi.Problem) { |
| | | formModalApi.setData({ formType: 'update', id: row.id }).open(); |
| | | } |
| | | |
| | | async function handleDelete(row: AftersalesProblemApi.Problem) { |
| | | await deleteProblem(row.id!); |
| | | message.success('å 餿å'); |
| | | handleRefresh(); |
| | | } |
| | | |
| | | async function handlePublish(row: AftersalesProblemApi.Problem) { |
| | | await publishProblem(row.id!); |
| | | message.success('å叿å'); |
| | | handleRefresh(); |
| | | } |
| | | |
| | | async function handleDisable(row: AftersalesProblemApi.Problem) { |
| | | await disableProblem(row.id!); |
| | | message.success('åç¨æå'); |
| | | handleRefresh(); |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: useProblemSearchFormSchema(), |
| | | }, |
| | | gridOptions: { |
| | | columns: useProblemGridColumns(), |
| | | height: 'auto', |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => { |
| | | return await getProblemPage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | ...formValues, |
| | | }); |
| | | }, |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | isHover: true, |
| | | }, |
| | | toolbarConfig: { |
| | | refresh: true, |
| | | search: true, |
| | | }, |
| | | } as VxeTableGridOptions<AftersalesProblemApi.Problem>, |
| | | }); |
| | | </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:problem: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> |
| | | <span v-else>-</span> |
| | | </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> |
| | | <span v-else>-</span> |
| | | </template> |
| | | |
| | | <template #rootCauseCategory="{ row }"> |
| | | <span>{{ rootCauseLabelMap[row.rootCauseCategory!] || '-' }}</span> |
| | | </template> |
| | | |
| | | <template #status="{ row }"> |
| | | <Tag |
| | | :color=" |
| | | row.status === 1 |
| | | ? 'green' |
| | | : row.status === 0 |
| | | ? 'default' |
| | | : 'orange' |
| | | " |
| | | > |
| | | {{ statusLabelMap[row.status!] || '-' }} |
| | | </Tag> |
| | | </template> |
| | | |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'æ¥ç', |
| | | type: 'link', |
| | | icon: ACTION_ICON.VIEW, |
| | | onClick: handleDetail.bind(null, row), |
| | | }, |
| | | { |
| | | label: 'ç¼è¾', |
| | | type: 'link', |
| | | icon: ACTION_ICON.EDIT, |
| | | auth: ['aftersales:problem:update'], |
| | | ifShow: row.status === 0, |
| | | onClick: handleEdit.bind(null, row), |
| | | }, |
| | | { |
| | | label: 'åå¸', |
| | | type: 'link', |
| | | auth: ['aftersales:problem:publish'], |
| | | ifShow: row.status === 0 || row.status === 2, |
| | | popConfirm: { |
| | | title: '确认åå¸è¯¥é®é¢ï¼', |
| | | confirm: handlePublish.bind(null, row), |
| | | }, |
| | | }, |
| | | { |
| | | label: 'åç¨', |
| | | type: 'link', |
| | | auth: ['aftersales:problem:disable'], |
| | | ifShow: row.status === 1, |
| | | popConfirm: { |
| | | title: '确认åç¨è¯¥é®é¢ï¼', |
| | | confirm: handleDisable.bind(null, row), |
| | | }, |
| | | }, |
| | | { |
| | | label: 'å é¤', |
| | | type: 'link', |
| | | danger: true, |
| | | icon: ACTION_ICON.DELETE, |
| | | auth: ['aftersales:problem:delete'], |
| | | ifShow: row.status === 0, |
| | | popConfirm: { |
| | | title: `确认å é¤é®é¢ã${row.no}ãï¼`, |
| | | confirm: handleDelete.bind(null, row), |
| | | }, |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { ProblemFormType } from '../data'; |
| | | |
| | | import type { AftersalesProblemApi } from '#/api/aftersales/problem'; |
| | | |
| | | import { computed, ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { Button, message } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm } from '#/adapter/form'; |
| | | import { |
| | | createProblem, |
| | | getProblem, |
| | | updateProblem, |
| | | } from '#/api/aftersales/problem'; |
| | | |
| | | import { useProblemFormSchema } from '../data'; |
| | | |
| | | defineOptions({ name: 'AftersalesProblemForm' }); |
| | | |
| | | const emit = defineEmits(['success']); |
| | | |
| | | const formType = ref<ProblemFormType>('create'); |
| | | const id = ref<number>(); |
| | | const submitting = ref(false); |
| | | |
| | | const isEdit = computed(() => formType.value !== 'detail'); |
| | | const title = computed(() => { |
| | | if (formType.value === 'detail') return 'æ¥çå®åé®é¢'; |
| | | if (id.value) return 'ç¼è¾å®åé®é¢'; |
| | | return 'æ°å¢å®åé®é¢'; |
| | | }); |
| | | |
| | | // 主表å |
| | | const [Form, formApi] = useVbenForm({ |
| | | commonConfig: { componentProps: { class: 'w-full' }, formItemClass: 'col-span-1', labelWidth: 100 }, |
| | | layout: 'horizontal', |
| | | schema: [], |
| | | showDefaultActions: false, |
| | | wrapperClass: 'grid-cols-2', |
| | | }); |
| | | |
| | | /** æé®é¢å¯¹è±¡æ å°æè¡¨åå¼ï¼é件转为 FileUpload ç»æï¼ */ |
| | | function problemToForm(p: AftersalesProblemApi.Problem): Record<string, unknown> { |
| | | const form: Record<string, unknown> = { ...p }; |
| | | if (p.attachmentList?.length) { |
| | | form.blobIds = p.attachmentList.map((item) => ({ |
| | | uid: String(item.id), |
| | | name: item.name || '', |
| | | url: item.url || '', |
| | | status: 'done', |
| | | id: item.id, |
| | | })); |
| | | } |
| | | return form; |
| | | } |
| | | |
| | | /** æè¡¨åéçéä»¶å¼è§æ´æ blobId æ°ç» */ |
| | | function normalizeBlobIds(value: unknown): number[] { |
| | | const arr = Array.isArray(value) ? value : []; |
| | | return arr |
| | | .map((x) => |
| | | typeof x === 'number' |
| | | ? x |
| | | : x && typeof x === 'object' && 'id' in (x as Record<string, unknown>) |
| | | ? Number((x as Record<string, unknown>).id) |
| | | : Number(x), |
| | | ) |
| | | .filter((n) => Number.isFinite(n)); |
| | | } |
| | | |
| | | // Modal |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | async onOpenChange(isOpen: boolean) { |
| | | if (!isOpen) { |
| | | id.value = undefined; |
| | | return; |
| | | } |
| | | const data = modalApi.getData<{ formType: ProblemFormType; id?: number }>(); |
| | | formType.value = data.formType; |
| | | id.value = data.id; |
| | | formApi.setState({ schema: useProblemFormSchema(formType.value) }); |
| | | formApi.setDisabled(formType.value === 'detail'); |
| | | if (data.id) { |
| | | modalApi.lock(); |
| | | try { |
| | | const detail = await getProblem(data.id); |
| | | await formApi.setValues(problemToForm(detail)); |
| | | } finally { |
| | | modalApi.unlock(); |
| | | } |
| | | } else { |
| | | await formApi.resetForm(); |
| | | } |
| | | }, |
| | | }); |
| | | |
| | | async function handleSubmit() { |
| | | const { valid } = await formApi.validate(); |
| | | if (!valid) { |
| | | return; |
| | | } |
| | | submitting.value = true; |
| | | try { |
| | | const data = (await formApi.getValues()) as Record<string, unknown>; |
| | | const blobIds = normalizeBlobIds(data.blobIds); |
| | | const payload = { ...data, blobIds } as AftersalesProblemApi.Problem; |
| | | if (id.value) { |
| | | await updateProblem({ ...payload, id: id.value }); |
| | | } else { |
| | | await createProblem(payload); |
| | | } |
| | | message.success('æä½æå'); |
| | | await modalApi.close(); |
| | | emit('success'); |
| | | } catch (e: unknown) { |
| | | message.error((e as Error).message || 'æäº¤å¤±è´¥'); |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal :title="title" class="w-2/3"> |
| | | <Form class="mx-4" /> |
| | | |
| | | <template #footer> |
| | | <div class="flex justify-end gap-2"> |
| | | <Button @click="modalApi.close()">åæ¶</Button> |
| | | <Button v-if="isEdit" type="primary" :loading="submitting" @click="handleSubmit"> |
| | | 确认 |
| | | </Button> |
| | | <Button v-else type="primary" @click="modalApi.close()">å
³é</Button> |
| | | </div> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | |
| | | 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 { |
| | | Button, |
| | | Card, |
| | | Descriptions, |
| | | Input, |
| | | message, |
| | | Popconfirm, |
| | | Select, |
| | | Switch, |
| | | Tag, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { |
| | | assignTicket, |
| | |
| | | getTicket, |
| | | resolveTicket, |
| | | } from '#/api/aftersales/ticket'; |
| | | import type { AftersalesVisitApi } from '#/api/aftersales/visit'; |
| | | import { |
| | | createVisit, |
| | | getVisitListByTicketId, |
| | | } from '#/api/aftersales/visit'; |
| | | |
| | | defineOptions({ name: 'AftersalesTicketDetail' }); |
| | | |
| | |
| | | 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 isVisitPassed = computed(() => ticket.value?.visitStatus === 1); |
| | | |
| | | // åè®¿è®°å½ |
| | | const visits = ref<AftersalesVisitApi.Visit[]>([]); |
| | | const showVisitForm = ref(false); |
| | | const visitForm = ref<AftersalesVisitApi.CreateVisit>({ |
| | | ticketId: 0, |
| | | visitType: 0, |
| | | content: '', |
| | | satisfied: true, |
| | | recurred: false, |
| | | }); |
| | | |
| | | const visitTypeOptions = [ |
| | | { label: 'çµè¯å访', value: 0 }, |
| | | { label: 'ä¸é¨å访', value: 1 }, |
| | | { label: 'è¿ç¨å访', value: 2 }, |
| | | { label: 'å
¶ä»', value: 3 }, |
| | | ]; |
| | | const visitTypeMap: Record<number, string> = { |
| | | 0: 'çµè¯', |
| | | 1: 'ä¸é¨', |
| | | 2: 'è¿ç¨', |
| | | 3: 'å
¶ä»', |
| | | }; |
| | | |
| | | // åé
å¤ç人 |
| | | const showAssignInput = ref(false); |
| | |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function loadVisits() { |
| | | visits.value = await getVisitListByTicketId(ticketId.value); |
| | | } |
| | | |
| | | function toggleVisitForm() { |
| | | showVisitForm.value = !showVisitForm.value; |
| | | if (showVisitForm.value) { |
| | | visitForm.value = { |
| | | ticketId: ticketId.value, |
| | | visitType: 0, |
| | | content: '', |
| | | satisfied: true, |
| | | recurred: false, |
| | | }; |
| | | } |
| | | } |
| | | |
| | | async function handleCreateVisit() { |
| | | await createVisit({ ...visitForm.value, ticketId: ticketId.value }); |
| | | message.success('å访ç»è®°æå'); |
| | | showVisitForm.value = false; |
| | | await Promise.all([loadVisits(), loadTicket()]); |
| | | } |
| | | |
| | | function toggleAssignInput() { |
| | |
| | | |
| | | onMounted(() => { |
| | | loadTicket(); |
| | | loadVisits(); |
| | | }); |
| | | </script> |
| | | |
| | |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="è´è´£äºº">{{ ticket.ownerUserName || '-' }}</Descriptions.Item> |
| | | <Descriptions.Item label="å¤ç人">{{ ticket.handlerUserName || '-' }}</Descriptions.Item> |
| | | <Descriptions.Item label="åè®¿ç¶æ"> |
| | | <Tag v-if="ticket.visitStatus === 1" color="green">å·²å访éè¿</Tag> |
| | | <Tag v-else color="orange">å¾
å访</Tag> |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="é®é¢æ²æ·" :span="2"> |
| | | <template v-if="ticket.problemNo">{{ ticket.problemNo }}</template> |
| | | <template v-else>-</template> |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="é®é¢æè¿°" :span="3"> |
| | | {{ ticket.issueDescription || '-' }} |
| | | </Descriptions.Item> |
| | |
| | | > |
| | | {{ file.name || 'éä»¶-' + file.id }} |
| | | </a> |
| | | </div> |
| | | </Card> |
| | | |
| | | <!-- åè®¿è®°å½ --> |
| | | <Card v-if="ticket" title="å访记å½" class="mb-4"> |
| | | <template #extra> |
| | | <Button |
| | | v-if="isResolved && !showVisitForm" |
| | | type="primary" |
| | | size="small" |
| | | @click="toggleVisitForm" |
| | | > |
| | | ç»è®°å访 |
| | | </Button> |
| | | </template> |
| | | |
| | | <div v-if="isResolved" class="mb-3 text-sm text-gray-500"> |
| | | ç»è®°ã满æ ä¸ æªå¤åãçå访åï¼å·¥åæå¯å½æ¡£ï¼ç»è®°ã䏿»¡ææå¤åãä¼èªå¨éåå¤çä¸ã |
| | | </div> |
| | | |
| | | <!-- ç»è®°å访表å --> |
| | | <div |
| | | v-if="showVisitForm" |
| | | class="mb-4 flex flex-wrap items-center gap-3 rounded bg-gray-50 p-3" |
| | | > |
| | | <div class="flex items-center gap-1"> |
| | | <span class="text-sm text-gray-500">å访æ¹å¼ï¼</span> |
| | | <Select |
| | | v-model:value="visitForm.visitType" |
| | | :options="visitTypeOptions" |
| | | style="width: 120px" |
| | | /> |
| | | </div> |
| | | <div class="flex items-center gap-1"> |
| | | <span class="text-sm text-gray-500">å访å
容ï¼</span> |
| | | <Input |
| | | v-model:value="visitForm.content" |
| | | placeholder="客æ·åé¦å
容" |
| | | style="width: 260px" |
| | | /> |
| | | </div> |
| | | <div class="flex items-center gap-1"> |
| | | <span class="text-sm text-gray-500">æ¯å¦æ»¡æï¼</span> |
| | | <Switch |
| | | v-model:checked="visitForm.satisfied" |
| | | checked-children="满æ" |
| | | un-checked-children="䏿»¡æ" |
| | | /> |
| | | </div> |
| | | <div class="flex items-center gap-1"> |
| | | <span class="text-sm text-gray-500">æ¯å¦å¤åï¼</span> |
| | | <Switch |
| | | v-model:checked="visitForm.recurred" |
| | | checked-children="å¤å" |
| | | un-checked-children="æªå¤å" |
| | | /> |
| | | </div> |
| | | <Button type="primary" size="small" @click="handleCreateVisit">æäº¤</Button> |
| | | <Button size="small" @click="toggleVisitForm">åæ¶</Button> |
| | | </div> |
| | | |
| | | <!-- å访记å½å表 --> |
| | | <div v-if="visits.length === 0" class="text-sm text-gray-400">ææ å访记å½</div> |
| | | <div v-else 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-center">满æ</th> |
| | | <th class="border p-2 text-center">å¤å</th> |
| | | <th class="border p-2 text-left">å
容</th> |
| | | <th class="border p-2 text-left">夿³¨</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tr v-for="visit in visits" :key="visit.id"> |
| | | <td class="border p-2">{{ visit.visitTime }}</td> |
| | | <td class="border p-2">{{ visitTypeMap[visit.visitType!] ?? '-' }}</td> |
| | | <td class="border p-2">{{ visit.visitorUserName || '-' }}</td> |
| | | <td class="border p-2 text-center"> |
| | | <Tag :color="visit.satisfied ? 'green' : 'red'"> |
| | | {{ visit.satisfied ? '满æ' : '䏿»¡æ' }} |
| | | </Tag> |
| | | </td> |
| | | <td class="border p-2 text-center"> |
| | | <Tag v-if="visit.recurred" color="orange">å¤å</Tag> |
| | | <Tag v-else color="default">æªå¤å</Tag> |
| | | </td> |
| | | <td class="border p-2">{{ visit.content || '-' }}</td> |
| | | <td class="border p-2">{{ visit.remark || '-' }}</td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | </Card> |
| | | |
| | |
| | | <Button type="primary">å®ç»</Button> |
| | | </Popconfirm> |
| | | |
| | | <!-- å
³é --> |
| | | <!-- 彿¡£ï¼ä»
å访éè¿åå¯å½æ¡£ï¼æªå访请å¨âå访记å½âéç»è®°ï¼ --> |
| | | <Popconfirm |
| | | v-if="isResolved" |
| | | title="确认å
³é该工åï¼å
³éåä¸å¯æ¢å¤ã" |
| | | v-if="isResolved && isVisitPassed" |
| | | title="ç¡®è®¤å½æ¡£è¯¥å·¥åï¼å½æ¡£åèªå¨æ²æ·ä¸æ¡é®é¢ï¼å·¥ååªè¯»ä¸å¯æ¢å¤ã" |
| | | @confirm="handleClose" |
| | | > |
| | | <Button type="primary">å
³é</Button> |
| | | <Button type="primary">彿¡£</Button> |
| | | </Popconfirm> |
| | | |
| | | <!-- åæ¶ --> |
| | |
| | | import type { AftersalesTicketApi } from '#/api/aftersales/ticket'; |
| | | |
| | | import { ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { Page, useVbenModal } from '@vben/common-ui'; |
| | | import { AftersaleTicketStatusEnum } from '@vben/constants'; |
| | |
| | | |
| | | import { useTicketGridColumns, useTicketGridFormSchema } from './data'; |
| | | import Form from './modules/form.vue'; |
| | | |
| | | const router = useRouter(); |
| | | |
| | | /** æ¥çå·¥åï¼è¿å
¥è·¯ç±è¯¦æ
页ï¼å¯ç»è®°å访/彿¡£ï¼ */ |
| | | function handleDetail(row: AftersalesTicketApi.Ticket) { |
| | | router.push({ |
| | | name: 'AftersalesTicketDetail', |
| | | params: { id: row.id }, |
| | | }); |
| | | } |
| | | |
| | | const sceneType = ref('1'); |
| | | |
| | |
| | | /** å建工å */ |
| | | function handleCreate() { |
| | | formModalApi.setData({ formType: 'create' }).open(); |
| | | } |
| | | |
| | | /** æ¥çå·¥å */ |
| | | function handleDetail(row: AftersalesTicketApi.Ticket) { |
| | | formModalApi.setData({ formType: 'detail', id: row.id }).open(); |
| | | } |
| | | |
| | | /** ç¼è¾å·¥å */ |