liu
22 小时以前 5a322d24b59c4b70c08e792f21162be0f05d0199
feat(aftersales): 工单回访/归档 + 问题库页面

- 工单详情新增「回访状态」「回访记录(登记回访)」与「归档」按钮(回访通过后才显示)
- 工单列表点单号进入路由详情页(可回访/归档)
- 新增问题库页面(列表/新增/编辑/删除/发布/停用)及 visit/problem API
- 修正详情页 Descriptions 列 span 布局告警

Co-Authored-By: Claude <noreply@anthropic.com>
已添加5个文件
已修改3个文件
973 ■■■■■ 文件已修改
src/api/aftersales/problem.ts 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/ticket.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/visit.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/problem/data.ts 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/problem/index.vue 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/problem/modules/form.vue 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/detail.vue 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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 },
  );
}
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;
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 },
  );
}
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: '解决方案',
      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' } },
  ];
}
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>
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>
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>
        <!-- å–消 -->
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();
}
/** ç¼–辑工单 */