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