From 5a322d24b59c4b70c08e792f21162be0f05d0199 Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期一, 14 九月 2026 17:44:45 +0800
Subject: [PATCH] feat(aftersales): 工单回访/归档 + 问题库页面
---
src/views/aftersales/problem/data.ts | 252 ++++++++++++++
src/views/aftersales/problem/modules/form.vue | 139 ++++++++
src/api/aftersales/visit.ts | 62 +++
src/api/aftersales/problem.ts | 103 ++++++
src/api/aftersales/ticket.ts | 8
src/views/aftersales/ticket/index.vue | 16
src/views/aftersales/ticket/detail.vue | 179 ++++++++++
src/views/aftersales/problem/index.vue | 214 ++++++++++++
8 files changed, 963 insertions(+), 10 deletions(-)
diff --git a/src/api/aftersales/problem.ts b/src/api/aftersales/problem.ts
new file mode 100644
index 0000000..313f3ba
--- /dev/null
+++ b/src/api/aftersales/problem.ts
@@ -0,0 +1,103 @@
+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 },
+ );
+}
diff --git a/src/api/aftersales/ticket.ts b/src/api/aftersales/ticket.ts
index 36120d0..ad1056e 100644
--- a/src/api/aftersales/ticket.ts
+++ b/src/api/aftersales/ticket.ts
@@ -46,6 +46,14 @@
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;
diff --git a/src/api/aftersales/visit.ts b/src/api/aftersales/visit.ts
new file mode 100644
index 0000000..d9ccd3b
--- /dev/null
+++ b/src/api/aftersales/visit.ts
@@ -0,0 +1,62 @@
+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 },
+ );
+}
diff --git a/src/views/aftersales/problem/data.ts b/src/views/aftersales/problem/data.ts
new file mode 100644
index 0000000..f6000f9
--- /dev/null
+++ b/src/views/aftersales/problem/data.ts
@@ -0,0 +1,252 @@
+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: '瑙e喅鏂规',
+ 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' } },
+ ];
+}
diff --git a/src/views/aftersales/problem/index.vue b/src/views/aftersales/problem/index.vue
new file mode 100644
index 0000000..372c34e
--- /dev/null
+++ b/src/views/aftersales/problem/index.vue
@@ -0,0 +1,214 @@
+<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>
diff --git a/src/views/aftersales/problem/modules/form.vue b/src/views/aftersales/problem/modules/form.vue
new file mode 100644
index 0000000..37ec0ec
--- /dev/null
+++ b/src/views/aftersales/problem/modules/form.vue
@@ -0,0 +1,139 @@
+<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>
diff --git a/src/views/aftersales/ticket/detail.vue b/src/views/aftersales/ticket/detail.vue
index b9a6d7e..092ebc2 100644
--- a/src/views/aftersales/ticket/detail.vue
+++ b/src/views/aftersales/ticket/detail.vue
@@ -7,7 +7,17 @@
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,
@@ -17,6 +27,11 @@
getTicket,
resolveTicket,
} from '#/api/aftersales/ticket';
+import type { AftersalesVisitApi } from '#/api/aftersales/visit';
+import {
+ createVisit,
+ getVisitListByTicketId,
+} from '#/api/aftersales/visit';
defineOptions({ name: 'AftersalesTicketDetail' });
@@ -32,6 +47,32 @@
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);
@@ -86,6 +127,30 @@
} 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() {
@@ -161,6 +226,7 @@
onMounted(() => {
loadTicket();
+ loadVisits();
});
</script>
@@ -195,6 +261,14 @@
</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>
@@ -253,6 +327,101 @@
>
{{ 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>
@@ -323,13 +492,13 @@
<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>
<!-- 鍙栨秷 -->
diff --git a/src/views/aftersales/ticket/index.vue b/src/views/aftersales/ticket/index.vue
index 269a460..edd0d78 100644
--- a/src/views/aftersales/ticket/index.vue
+++ b/src/views/aftersales/ticket/index.vue
@@ -3,6 +3,7 @@
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';
@@ -16,6 +17,16 @@
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');
@@ -38,11 +49,6 @@
/** 鍒涘缓宸ュ崟 */
function handleCreate() {
formModalApi.setData({ formType: 'create' }).open();
-}
-
-/** 鏌ョ湅宸ュ崟 */
-function handleDetail(row: AftersalesTicketApi.Ticket) {
- formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** 缂栬緫宸ュ崟 */
--
Gitblit v1.9.3