hejianhao
2026-02-28 58fa2093c53388ec8b569057b21cda70c4f1809f
feat(客服工单): 新增客服工单管理模块

- 添加静态路由配置,包括客服工单、意见反馈、反馈类型和工单详情页面
- 新增API接口函数,支持反馈列表、反馈类型CRUD和工单管理操作
- 实现反馈类型管理页面,支持增删改查操作
- 实现意见反馈列表页面,支持查询和图片预览功能
- 实现工单管理页面,支持创建、编辑、删除、处理和查看详情功能
- 添加工单详情页面,展示处理流程和文件管理功能
- 新增工单创建/编辑弹窗组件,支持表单验证和图片上传
- 新增工单处理弹窗组件,支持处理日志记录和文件上传
6个文件已添加
2个文件已修改
2141 ■■■■■ 已修改文件
src/router/routes.static.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/serviceCenter.ts 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/feedback/index.vue 286 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/feedbackType/index.vue 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/components/WorkOrderHandleModal.vue 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/components/WorkOrderModal.vue 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/detail.vue 330 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/index.vue 564 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts
@@ -828,6 +828,17 @@
    pid: 8,
  },
  {
    name: 'serviceTicket',
    path: '/service/ticket',
    title: '客服工单',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'dir',
    componentPath: null,
    id: 802,
    pid: 8,
  },
  {
    name: 'serviceReviewPersonnel',
    path: '/service/review/personnel',
    title: '人员资质审核',
@@ -849,5 +860,51 @@
    id: 8012,
    pid: 801,
  },
  {
    name: 'serviceTicketFeedback',
    path: '/service/ticket/feedback',
    title: '意见反馈',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/feedback/index.vue',
    id: 8021,
    pid: 802,
  },
  {
    name: 'serviceTicketFeedbackType',
    path: '/service/ticket/feedbackType',
    title: '反馈类型',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/feedbackType/index.vue',
    id: 8022,
    pid: 802,
  },
  {
    name: 'serviceTicketWorkOrder',
    path: '/service/ticket/workOrder',
    title: '工单',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/workOrder/index.vue',
    id: 8023,
    pid: 802,
  },
  {
    name: 'serviceTicketWorkOrderDetail',
    path: '/service/ticket/workOrder/detail',
    title: '工单详情',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/workOrder/detail.vue',
    id: 80231,
    pid: 802,
    hide: true,
    activeMenu: '/service/ticket/workOrder',
  },
]
src/service/api/serviceCenter.ts
@@ -42,3 +42,96 @@
export function deletePolicyApplicationById(id: number | string) {
  return request.Delete<Service.ResponseResult<any>>(`/vehicleConvoy/api/v1/policyApplication/deleteById/${id}`)
}
export function fetchFeedbackPageList(data: {
  content?: string
  pageNum: number
  pageSize: number
}) {
  return request.Post<Service.ResponseResult<any>>('/order/api/v1/orderEvaluateManagement/pageList', data)
}
export function fetchFeedbackTypeList(data: {
  pageNum: number
  pageSize: number
}) {
  return request.Get<Service.ResponseResult<any>>('/operation/api/v1/feedbackType/getList', data)
}
export function saveFeedbackType(data: { feedbackType: string }) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/feedbackType/saveFeedbackType', data)
}
export function updateFeedbackType(data: { id: number | string; feedbackType: string }) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/feedbackType/updateFeedbackType', data)
}
export function deleteFeedbackType(id: number | string) {
  return request.Delete<Service.ResponseResult<any>>(`/operation/api/v1/feedbackType/deleteFeedbackType?id=${id}`)
}
export function fetchWorkOrderPageList(data: {
  feedbackTypeId?: number | string
  feedbackUserPhone?: string
  handleStatus?: number | null
  pageNum: number
  pageSize: number
  priority?: number | null
  problemSource?: number | null
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/pageList', data)
}
export function fetchOperationsUserListAll() {
  return request.Get<Service.ResponseResult<any>>('/account/api/v1/sys-user/listAll', {})
}
export function saveWorkOrder(data: {
  feedbackTypeId?: number | string
  feedbackUserPhone?: string
  handleStatus?: number | null
  priority?: number | null
  problemSource?: number | null
  problem?: string
  problemDescription?: string
  problemImage?: string
  handleUserId?: number | string
  handleUserName?: string
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/saveWorkOrder', data)
}
export function updateWorkOrder(data: {
  id: number | string
  feedbackTypeId?: number | string
  feedbackUserPhone?: string
  handleStatus?: number | null
  priority?: number | null
  problemSource?: number | null
  problem?: string
  problemDescription?: string
  handleUserId?: number | string
  problemImage?: string
  handleUserName?: string
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/updateWorkOrder', data)
}
export function deleteWorkOrder(id: number | string) {
  return request.Delete<Service.ResponseResult<any>>(`/maintenance/api/v1/workOrder/deleteWorkOrder?id=${id}`)
}
export function fetchWorkOrderDetail(id: number | string) {
  return request.Get<Service.ResponseResult<any>>(`/maintenance/api/v1/workOrder/workOrderDetail?id=${id}`)
}
export function handleWorkOrder(data: {
  handleFile?: string
  handleLog: string
  handleType: number
  handleUserId?: number | string
  handleUserName?: string
  workOrderId: number | string
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/handleWorkOrder', data)
}
src/views/service/ticket/feedback/index.vue
New file
@@ -0,0 +1,286 @@
<script setup lang="ts">
import { ref, h, onMounted } from "vue";
import type { DataTableColumns } from "naive-ui";
import {
  NButton,
  NImage,
  NImageGroup,
  NModal,
  NSpace,
  NAvatar,
} from "naive-ui";
import { useBoolean } from "@/hooks";
import { fetchFeedbackPageList } from "@/service/api/serviceCenter";
const {
  bool: loading,
  setTrue: startLoading,
  setFalse: endLoading,
} = useBoolean(false);
const initialSearchForm = {
  feedbackContent: "",
};
const searchForm = ref({ ...initialSearchForm });
const tableData = ref<any[]>([]);
const pagination = ref({
  page: 1,
  pageSize: 10,
  itemCount: 0,
  showSizePicker: true,
  pageSizes: [10, 20, 50, 100],
});
const previewVisible = ref(false);
const previewImages = ref<string[]>([]);
function normalizeImages(value: any) {
  if (!value) return [];
  if (Array.isArray(value)) {
    return value.filter((item) => typeof item === "string" && item);
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (!trimmed) return [];
    if (trimmed.startsWith("[")) {
      try {
        const parsed = JSON.parse(trimmed);
        if (Array.isArray(parsed)) {
          return parsed.filter((item) => typeof item === "string" && item);
        }
      } catch {
        return [trimmed];
      }
    }
    return trimmed.includes(",")
      ? trimmed
          .split(",")
          .map((item) => item.trim())
          .filter(Boolean)
      : [trimmed];
  }
  return [];
}
function getImages(row: any) {
  return normalizeImages(row.feedbackPictures);
}
const columns: DataTableColumns<FeedbackItem> = [
  {
    title: "序号",
    key: "index",
    width: 80,
    align: "center",
    render: (_row, index) =>
      h(
        "span",
        (pagination.value.page - 1) * pagination.value.pageSize + index + 1
      ),
  },
  {
    title: "反馈人",
    key: "nickName",
    width: 120,
    align: "center",
    render: (row) => {
      const displayName = row.nickName || "-";
      return h("div", { class: "flex items-center justify-center gap-2" }, [
        h(
          NAvatar,
          {
            round: true,
            size: 32,
            style: { backgroundColor: "#e0e0e0" },
            src: row.avatar || undefined,
          },
          { default: () => displayName.charAt(0) }
        ),
        h("span", displayName),
      ]);
    },
  },
  {
    title: "反馈类型",
    key: "feedbackTypeName",
    width: 120,
    align: "center",
  },
  {
    title: "反馈内容",
    key: "feedbackContent",
    minWidth: 200,
    align: "center",
  },
  {
    title: "图片",
    key: "feedbackPictures",
    width: 120,
    align: "center",
    render: (row) => {
      const images = getImages(row);
      if (!images.length) return h("span", "-");
      return h(
        NButton,
        {
          text: true,
          type: "primary",
          onClick: () => handleViewImages(images),
        },
        { default: () => "查看" }
      );
    },
  },
  {
    title: "联系方式",
    key: "phoneNumber",
    width: 140,
    align: "center",
  },
  {
    title: "反馈时间",
    key: "createTime",
    width: 160,
    align: "center",
  },
];
const handleSearch = () => {
  pagination.value.page = 1;
  getFeedbackList();
};
const handleReset = () => {
  searchForm.value = { ...initialSearchForm };
  pagination.value.page = 1;
  getFeedbackList();
};
function handleViewImages(images: string[]) {
  previewImages.value = images;
  previewVisible.value = true;
}
function handlePageChange(page: number) {
  pagination.value.page = page;
  getFeedbackList();
}
function handlePageSizeChange(pageSize: number) {
  pagination.value.pageSize = pageSize;
  pagination.value.page = 1;
  getFeedbackList();
}
async function getFeedbackList() {
  startLoading();
  try {
    const res = await fetchFeedbackPageList({
      content: searchForm.value.feedbackContent || undefined,
      pageNum: pagination.value.page,
      pageSize: pagination.value.pageSize,
    });
    if (res.code === 200 && res.data) {
      tableData.value = res.data.records || [];
      pagination.value.itemCount = res.data.total || 0;
    } else {
      tableData.value = [];
      pagination.value.itemCount = 0;
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getFeedbackList();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form
        :model="searchForm"
        label-placement="left"
        inline
        :show-feedback="false"
      >
        <div class="flex justify-between items-center w-full">
          <div class="flex gap-4">
            <n-form-item label="反馈内容">
              <n-input
                v-model:value="searchForm.feedbackContent"
                placeholder="请输入"
                clearable
                style="width: 200px"
              />
            </n-form-item>
          </div>
          <div class="flex gap-2">
            <n-button style="width: 84px; height: 32px" @click="handleReset"
              >重置</n-button
            >
            <n-button
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
              >查询</n-button
            >
          </div>
        </div>
      </n-form>
    </n-card>
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <n-data-table
        :columns="columns"
        :data="tableData"
        :loading="loading"
        :pagination="{
          page: pagination.page,
          pageSize: pagination.pageSize,
          itemCount: pagination.itemCount,
          showSizePicker: true,
          pageSizes: pagination.pageSizes,
          prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`,
        }"
        :scroll-x="1200"
        :row-key="(row: FeedbackItem) => row.id"
        @update:page="handlePageChange"
        @update:page-size="handlePageSizeChange"
      />
    </n-card>
    <n-modal
      v-model:show="previewVisible"
      preset="card"
      title="图片预览"
      :style="{ width: '720px' }"
      :bordered="false"
      :segmented="{ content: true, action: true }"
    >
      <n-image-group v-if="previewImages.length">
        <n-space wrap>
          <n-image
            v-for="(img, index) in previewImages"
            :key="index"
            :src="img"
            :width="120"
            :height="90"
            object-fit="cover"
          />
        </n-space>
      </n-image-group>
      <span v-else>-</span>
    </n-modal>
  </div>
</template>
<style scoped>
</style>
src/views/service/ticket/feedbackType/index.vue
New file
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, h, computed, onMounted } from "vue";
import type { DataTableColumns, FormInst, FormRules } from "naive-ui";
import { NButton } from "naive-ui";
import { useBoolean } from "@/hooks";
import {
  fetchFeedbackTypeList,
  saveFeedbackType,
  updateFeedbackType,
  deleteFeedbackType,
} from "@/service/api/serviceCenter";
const { bool: loading, setTrue: startLoading, setFalse: endLoading } =
  useBoolean(false);
const tableData = ref<any[]>([]);
const modalVisible = ref(false);
const modalMode = ref<"add" | "edit">("add");
const formRef = ref<FormInst | null>(null);
const formModel = ref({
  id: null as number | null,
  feedbackType: "",
});
const rules: FormRules = {
  feedbackType: [{ required: true, message: "请输入反馈类型", trigger: "blur" }],
};
const modalTitle = computed(() =>
  modalMode.value === "add" ? "新增反馈类型" : "编辑反馈类型"
);
const columns: DataTableColumns<any> = [
  {
    title: "序号",
    key: "index",
    width: 80,
    align: "center",
    render: (_row, index) => h("span", index + 1),
  },
  {
    title: "反馈类型",
    key: "feedbackType",
    minWidth: 200,
    align: "center",
  },
  {
    title: "创建时间",
    key: "createTime",
    width: 180,
    align: "center",
    render: (row) => row.createTime || "-",
  },
  {
    title: "操作",
    key: "actions",
    width: 160,
    align: "center",
    render: (row) =>
      h("div", { class: "flex items-center justify-center gap-2" }, [
        h(
          NButton,
          {
            text: true,
            type: "primary",
            onClick: () => handleEdit(row),
          },
          { default: () => "编辑" }
        ),
        h(
          NButton,
          {
            text: true,
            type: "error",
            onClick: () => handleDelete(row),
          },
          { default: () => "删除" }
        ),
      ]),
  },
];
function handleAdd() {
  modalMode.value = "add";
  formModel.value = { id: null, feedbackType: "" };
  modalVisible.value = true;
  formRef.value?.restoreValidation();
}
function handleEdit(row: FeedbackTypeItem) {
  modalMode.value = "edit";
  formModel.value = { id: row.id, feedbackType: row.feedbackType || "" };
  modalVisible.value = true;
  formRef.value?.restoreValidation();
}
function handleClose() {
  modalVisible.value = false;
}
async function handleSave() {
  await formRef.value?.validate();
  const payload = { feedbackType: formModel.value.feedbackType };
  if (modalMode.value === "add") {
    const { error } = await saveFeedbackType(payload);
    if (!error) {
      window.$message?.success("新增成功");
      modalVisible.value = false;
      getFeedbackTypeList();
    } else {
      window.$message?.error("新增失败");
    }
    return;
  }
  const { error } = await updateFeedbackType({
    id: formModel.value.id as number,
    ...payload,
  });
  if (!error) {
    window.$message?.success("编辑成功");
    modalVisible.value = false;
    getFeedbackTypeList();
  } else {
    window.$message?.error("编辑失败");
  }
}
function handleDelete(row: FeedbackTypeItem) {
  window.$dialog?.warning({
    title: "确认删除",
    content: "确定要删除该反馈类型吗?",
    positiveText: "确定",
    negativeText: "取消",
    onPositiveClick: async () => {
      const { error } = await deleteFeedbackType(row.id);
      if (!error) {
        window.$message?.success("删除成功");
        getFeedbackTypeList();
      } else {
        window.$message?.error("删除失败");
      }
    },
  });
}
async function getFeedbackTypeList() {
  startLoading();
  try {
    const res = await fetchFeedbackTypeList();
    if (res.code === 200 && res.data) {
      const list = Array.isArray(res.data)
        ? res.data
        : res.data.records || [];
      tableData.value = list;
    } else {
      tableData.value = [];
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getFeedbackTypeList();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <div class="mb-4 flex justify-end gap-2">
        <n-button
          style="
            width: 84px;
            height: 32px;
            color: #0068ff;
            background-color: #e6f4ff;
            border: 1px solid #7abdff;
          "
          @click="handleAdd"
          >新增</n-button
        >
      </div>
      <n-data-table
        :columns="columns"
        :data="tableData"
        :loading="loading"
        :pagination="false"
        :scroll-x="800"
        :row-key="(row: FeedbackTypeItem) => row.id"
      />
    </n-card>
    <n-modal
      v-model:show="modalVisible"
      preset="card"
      :title="modalTitle"
      :mask-closable="false"
      :style="{ width: '420px' }"
      :segmented="{ content: true, action: true }"
    >
      <n-form
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="left"
        label-width="80"
        require-mark-placement="left"
      >
        <n-form-item label="反馈类型" path="feedbackType">
          <n-input
            v-model:value="formModel.feedbackType"
            placeholder="请输入"
            clearable
          />
        </n-form-item>
      </n-form>
      <template #action>
        <n-space justify="end">
          <n-button @click="handleClose">取消</n-button>
          <n-button type="primary" @click="handleSave">保存</n-button>
        </n-space>
      </template>
    </n-modal>
  </div>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/components/WorkOrderHandleModal.vue
New file
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { FormInst, FormRules, UploadFileInfo } from "naive-ui";
import { customUploadRequest } from "@/utils/upload";
interface HandleFileItem {
  id: string;
  name: string;
  url: string;
}
interface Props {
  visible: boolean;
  rowData?: any;
  typeOptions: { label: string; value: number }[];
  problemSourceOptions: { label: string; value: number }[];
  priorityOptions: { label: string; value: number }[];
  handleUserOptions: { label: string; value: number | string }[];
}
const props = withDefaults(defineProps<Props>(), {
  visible: false,
  rowData: null,
  typeOptions: () => [],
  problemSourceOptions: () => [],
  priorityOptions: () => [],
  handleUserOptions: () => [],
});
const emit = defineEmits(["update:visible", "submit"]);
const showModal = computed({
  get: () => props.visible,
  set: (val) => emit("update:visible", val),
});
const formRef = ref<FormInst | null>(null);
const formModel = ref({
  handleLog: "",
  handleUserId: null as number | string | null,
  handleFile: [] as HandleFileItem[],
});
const fileList = ref<UploadFileInfo[]>([]);
const rules: FormRules = {
  handleLog: [{ required: true, message: "请输入处理情况", trigger: "blur" }],
};
const disableSave = computed(() => !formModel.value.handleUserId);
const disableClose = computed(() => !!formModel.value.handleUserId);
const displayType = computed(() => {
  const id = props.rowData?.feedbackTypeId;
  const matched = props.typeOptions.find((item) => item.value === id);
  return matched?.label || props.rowData?.feedbackTypeName || "-";
});
const displayProblemSource = computed(() => {
  const id = props.rowData?.problemSource;
  const matched = props.problemSourceOptions.find((item) => item.value === id);
  return matched?.label || "-";
});
const displayPriority = computed(() => {
  const id = props.rowData?.priority;
  const matched = props.priorityOptions.find((item) => item.value === id);
  return matched?.label || "-";
});
const displayImages = computed<HandleFileItem[]>(() => {
  const images = props.rowData?.problemImage;
  return images ? JSON.parse(images) : [];
});
function resetForm() {
  formModel.value = {
    handleLog: "",
    handleUserId: null,
    handleFile: [],
  };
  fileList.value = [];
  formRef.value?.restoreValidation();
}
async function handleUploadFile({ file, onFinish, onError, onProgress }: any) {
  const res = await customUploadRequest({
    file,
    onFinish,
    onError,
    onProgress,
  });
  if (res) {
    file.id = res.id;
    file.url = res.url;
    file.status = "finished";
    const nextItem = {
      id: String(res.id || file.id || Date.now()),
      name: file.name || "附件",
      url: res.url,
    };
    formModel.value.handleFile = [
      ...formModel.value.handleFile.filter((item) => item.id !== nextItem.id),
      nextItem,
    ];
    onFinish();
  }
}
function handleRemoveFile(
  _file: UploadFileInfo,
  fileListValue: UploadFileInfo[]
) {
  const ids = new Set(
    fileListValue
      .map((item) => String(item.id))
      .filter((id) => id && id !== "undefined")
  );
  formModel.value.handleFile = formModel.value.handleFile.filter((item) =>
    ids.has(String(item.id))
  );
}
async function handleSave() {
  await formRef.value?.validate();
  emit("submit", {
    handleType: 1,
    handleLog: formModel.value.handleLog,
    handleUserId: formModel.value.handleUserId,
    handleFile: JSON.stringify(formModel.value.handleFile),
  });
}
async function handleClose() {
  await formRef.value?.validate();
  emit("submit", {
    handleType: 2,
    handleLog: formModel.value.handleLog,
    handleUserId: null,
    handleFile: JSON.stringify(formModel.value.handleFile),
  });
}
watch(
  () => props.visible,
  (visible) => {
    if (visible) {
      resetForm();
    }
  }
);
</script>
<template>
  <n-modal
    v-model:show="showModal"
    preset="card"
    title="处理工单"
    :mask-closable="false"
    :style="{ width: '860px' }"
    :segmented="{ content: true, action: true }"
  >
    <div class="space-y-4">
      <div class="rounded border border-[#E6E6E6] p-3">
        <div class="grid grid-cols-2 gap-4 text-sm text-[#1D2129]">
          <div>问题:{{ props.rowData?.problem || "-" }}</div>
          <div>反馈用户:{{ props.rowData?.feedbackUserPhone || "-" }}</div>
          <div>来源:{{ displayProblemSource }}</div>
          <div>类型:{{ displayType }}</div>
          <div>优先级:{{ displayPriority }}</div>
        </div>
        <div class="mt-3 flex text-sm text-[#1D2129]">
          <div>问题描述:</div>
          <div>
            <div class="whitespace-pre-wrap text-[#4E5969]">
              {{ props.rowData?.problemDescription || "-" }}
            </div>
            <div v-if="displayImages.length" class="mt-3">
              <n-image-group>
                <div class="flex flex-wrap gap-2">
                  <n-image
                    v-for="item in displayImages"
                    :key="item.id || item.url"
                    :src="item.url"
                    width="80"
                    height="80"
                  />
                </div>
              </n-image-group>
            </div>
          </div>
        </div>
      </div>
      <n-form
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="left"
        label-width="90"
        require-mark-placement="left"
      >
        <div class="text-sm font-medium text-[#1D2129] mb-2">处理日志</div>
        <n-form-item label="处理情况" path="handleLog">
          <n-input
            v-model:value="formModel.handleLog"
            type="textarea"
            placeholder="请输入"
            :rows="4"
            clearable
          />
        </n-form-item>
        <n-form-item label="相关资料" path="handleFile" :show-feedback="false">
          <div class="w-full">
            <n-upload
              v-model:file-list="fileList"
              list-type="text"
              :max="10"
              accept="image/*,audio/*,video/*"
              :custom-request="handleUploadFile"
              @remove="handleRemoveFile"
            >
              <n-button>上传文件</n-button>
            </n-upload>
            <div class="mt-2 text-xs text-[#86909C]">
              上传通话录音、处理结果等资料。上传文件类型可选图片、音频、视频
            </div>
          </div>
        </n-form-item>
        <n-form-item label="移交" path="handleUserId">
          <n-select
            v-model:value="formModel.handleUserId"
            :options="handleUserOptions"
            placeholder="请选择"
            clearable
          />
        </n-form-item>
      </n-form>
    </div>
    <template #action>
      <n-space justify="end">
        <n-button @click="showModal = false">取消</n-button>
        <n-button v-if="!disableClose" type="primary" @click="handleClose">
          结办
        </n-button>
        <n-button v-if="!disableSave" type="primary" @click="handleSave">
          保存
        </n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/components/WorkOrderModal.vue
New file
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { FormInst, FormRules, UploadFileInfo } from "naive-ui";
import { customUploadRequest } from "@/utils/upload";
interface WorkOrderFormModel {
  id?: number | null;
  problem: string;
  problemDescription: string;
  problemImage: { id: string; name: string; url: string }[];
  feedbackUserPhone: number | string;
  problemSource: number | null;
  feedbackTypeId: number | null;
  priority: number | null;
  handleUserId: number | string | null;
}
interface Props {
  visible: boolean;
  mode: "add" | "edit";
  rowData?: any;
  typeOptions: { label: string; value: number }[];
  problemSourceOptions: { label: string; value: number }[];
  priorityOptions: { label: string; value: number }[];
  handleUserOptions: { label: string; value: number | string }[];
}
const props = withDefaults(defineProps<Props>(), {
  visible: false,
  mode: "add",
  rowData: null,
  typeOptions: () => [],
  problemSourceOptions: () => [],
  priorityOptions: () => [],
  handleUserOptions: () => [],
});
const emit = defineEmits(["update:visible", "submit"]);
const showModal = computed({
  get: () => props.visible,
  set: (val) => emit("update:visible", val),
});
const formRef = ref<FormInst | null>(null);
const formModel = ref<WorkOrderFormModel>({
  id: null,
  problem: "",
  problemDescription: "",
  problemImage: [],
  feedbackUserPhone: "",
  problemSource: null,
  feedbackTypeId: null,
  priority: null,
  handleUserId: null,
});
const fileList = ref<UploadFileInfo[]>([]);
const rules: FormRules = {
  problem: [{ required: true, message: "请输入问题", trigger: "blur" }],
  problemDescription: [
    { required: true, message: "请输入问题描述", trigger: "blur" },
  ],
  feedbackUserPhone: [
    { required: true, message: "请输入反馈用户", trigger: "blur" },
  ],
  problemSource: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择来源")
          : true,
      trigger: ["change", "blur"],
    },
  ],
  feedbackTypeId: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择类型")
          : true,
      trigger: ["change", "blur"],
    },
  ],
  priority: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择优先级")
          : true,
      trigger: ["change", "blur"],
    },
  ],
  handleUserId: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择处理人")
          : true,
      trigger: ["change", "blur"],
    },
  ],
};
const modalTitle = computed(() =>
  props.mode === "add" ? "新增工单" : "编辑工单"
);
function resetForm() {
  formModel.value = {
    id: null,
    problem: "",
    problemDescription: "",
    problemImage: [],
    feedbackUserPhone: "",
    problemSource: null,
    feedbackTypeId: null,
    priority: null,
    handleUserId: null,
  };
  fileList.value = [];
  formRef.value?.restoreValidation();
}
function fillForm() {
  const row = props.rowData || {};
  formModel.value = {
    problem: row.problem || "",
    problemDescription: row.problemDescription || "",
    problemImage: row.problemImage ? JSON.parse(row.problemImage) : [],
    feedbackUserPhone: row.feedbackUserPhone || "",
    problemSource: row.problemSource ?? null,
    feedbackTypeId: row.feedbackTypeId ?? null,
    priority: row.priority ?? null,
    handleUserId: row.handleUserId || null,
  };
  if (row.id) {
    formModel.value.id = row.id;
  }
  fileList.value = formModel.value.problemImage.map((item) => ({
    id: item.id || `image_${row.id || Date.now()}`,
    name: item.name || "图片",
    status: "finished",
    url: item.url,
  }));
  formRef.value?.restoreValidation();
}
async function handleUploadImage({ file, onFinish, onError, onProgress }: any) {
  const res = await customUploadRequest({
    file,
    onFinish,
    onError,
    onProgress,
  });
  if (res) {
    file.id = res.id;
    file.url = res.url;
    file.status = "finished";
    const nextItem = {
      id: String(res.id || file.id || Date.now()),
      name: file.name || "附件",
      url: res.url,
    };
    formModel.value.problemImage = [
      ...formModel.value.problemImage.filter((item) => item.id !== nextItem.id),
      nextItem,
    ];
    onFinish();
  }
}
function handleRemoveImage(
  _file: UploadFileInfo,
  fileListValue: UploadFileInfo[]
) {
  const ids = new Set(
    fileListValue
      .map((item) => String(item.id))
      .filter((id) => id && id !== "undefined")
  );
  formModel.value.problemImage = formModel.value.problemImage.filter((item) =>
    ids.has(String(item.id))
  );
}
async function handleSave() {
  await formRef.value?.validate();
  emit("submit", {
    ...formModel.value,
    problemImage: JSON.stringify(formModel.value.problemImage),
  });
}
watch(
  () => props.visible,
  (visible) => {
    if (!visible) return;
    if (props.mode === "edit") {
      fillForm();
    } else {
      resetForm();
    }
  }
);
</script>
<template>
  <n-modal
    v-model:show="showModal"
    preset="card"
    :title="modalTitle"
    :mask-closable="false"
    :style="{ width: '720px' }"
    :segmented="{ content: true, action: true }"
  >
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="left"
      label-width="90"
      require-mark-placement="left"
    >
      <n-form-item label="问题" path="problem">
        <n-input
          v-model:value="formModel.problem"
          placeholder="请输入"
          clearable
        />
      </n-form-item>
      <n-form-item label="问题描述" path="problemDescription">
        <n-input
          v-model:value="formModel.problemDescription"
          type="textarea"
          placeholder="请输入"
          :rows="4"
          clearable
        />
      </n-form-item>
      <n-form-item label="图片" path="problemImage">
        <div class="flex flex-col">
          <n-upload
            v-model:file-list="fileList"
            list-type="image-card"
            :max="10"
            :custom-request="handleUploadImage"
            @remove="handleRemoveImage"
            accept="image/*"
          >
            <div
              style="
                width: 120px;
                height: 120px;
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                color: #999;
              "
            >
              <icon-park-outline-plus style="font-size: 20px" />
              <div style="margin-top: 8px; font-size: 12px">点击上传图片</div>
            </div>
          </n-upload>
          <div style="margin-top: 8px; color: #999; font-size: 12px">
            支持jpg、png 格式 建议尺寸:X px Xpx<br />
          </div>
        </div>
      </n-form-item>
      <n-form-item label="反馈用户" path="feedbackUserPhone">
        <n-input
          v-model:value="formModel.feedbackUserPhone"
          placeholder="请输入"
          clearable
        />
      </n-form-item>
      <n-form-item label="来源" path="problemSource" required>
        <n-select
          v-model:value="formModel.problemSource"
          :options="problemSourceOptions"
          placeholder="请选择"
          clearable
        />
      </n-form-item>
      <div class="grid grid-cols-2 gap-4">
        <n-form-item label="类型" path="feedbackTypeId" required>
          <n-select
            v-model:value="formModel.feedbackTypeId"
            :options="typeOptions"
            placeholder="请选择"
            clearable
          />
        </n-form-item>
        <n-form-item label="优先级" path="priority" required>
          <n-select
            v-model:value="formModel.priority"
            :options="priorityOptions"
            placeholder="请选择"
            clearable
          />
        </n-form-item>
      </div>
      <n-form-item label="处理人" path="handleUserId" required>
        <n-select
          v-model:value="formModel.handleUserId"
          :options="handleUserOptions"
          placeholder="请选择"
          clearable
        />
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="showModal = false">取消</n-button>
        <n-button type="primary" @click="handleSave">保存</n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/detail.vue
New file
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useBoolean } from "@/hooks";
import { fetchWorkOrderDetail } from "@/service/api/serviceCenter";
const route = useRoute();
const router = useRouter();
const {
  bool: loading,
  setTrue: startLoading,
  setFalse: endLoading,
} = useBoolean(false);
const detailData = ref<any>({});
const workOrderId = computed(() => route.query.id);
const problemSourceLabelMap: Record<number, string> = {
  1: "电话客服",
};
const priorityLabelMap: Record<number, string> = {
  1: "低",
  2: "中",
  3: "高",
};
const displayProblemSource = computed(
  () => problemSourceLabelMap[detailData.value.problemSource] || "-"
);
const displayPriority = computed(
  () => priorityLabelMap[detailData.value.priority] || "-"
);
const displayType = computed(() => detailData.value.feedbackTypeName || "-");
const displayImages = computed(() =>
  parseFileList(detailData.value.problemImage)
);
const timelineItems = computed(() => {
  const items: any[] = [];
  if (detailData.value.createTime) {
    items.push({
      title: detailData.value.createTime,
      handleUserName: detailData.value.createBy,
      typeTxt: "创建工单",
    });
  }
  const logs = Array.isArray(detailData.value.workOrderLogs)
    ? detailData.value.workOrderLogs
    : [];
  logs.forEach((log: any) => {
    items.push({
      title: log.handleTime || log.createTime || "-",
      handleUserName: log.handleUserName || "-",
      typeTxt: "处理",
      log,
    });
  });
  return items;
});
function parseFileList(value: any) {
  if (!value) return [];
  if (Array.isArray(value)) return value;
  if (typeof value === "string") {
    try {
      const parsed = JSON.parse(value);
      return Array.isArray(parsed) ? parsed : [];
    } catch {
      return [];
    }
  }
  return [];
}
function handleTypeLabel(value: number) {
  if (value === 1) return "移交";
  if (value === 2) return "办结";
  return "-";
}
function getFileExt(fileName: string) {
  const normalized = fileName || "";
  const dotIndex = normalized.lastIndexOf(".");
  if (dotIndex === -1) return "";
  return normalized.slice(dotIndex + 1).toLowerCase();
}
function isImageFile(fileName: string) {
  return ["png", "jpg", "jpeg", "gif", "bmp", "webp"].includes(
    getFileExt(fileName)
  );
}
function isVideoFile(fileName: string) {
  return ["mp4", "mov", "avi", "wmv", "mkv", "webm"].includes(
    getFileExt(fileName)
  );
}
function isAudioFile(fileName: string) {
  return ["mp3", "wav", "aac", "flac", "ogg", "m4a"].includes(
    getFileExt(fileName)
  );
}
function isOfficeFile(fileName: string) {
  return ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf"].includes(
    getFileExt(fileName)
  );
}
function openNewTab(url: string) {
  if (!url) {
    window.$message?.warning("文件地址不存在");
    return;
  }
  window.open(url, "_blank", "noopener");
}
function handlePreview(file: any) {
  const url = file?.url;
  const name = file?.name || "";
  if (isOfficeFile(name)) {
    const previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(
      url || ""
    )}`;
    openNewTab(previewUrl);
    return;
  }
  if (isImageFile(name) || isVideoFile(name) || isAudioFile(name)) {
    openNewTab(url);
    return;
  }
  openNewTab(url);
}
async function handleDownload(file: any) {
  const url = file?.url;
  if (!url) {
    window.$message?.warning("文件地址不存在");
    return;
  }
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error("download_failed");
    }
    const blob = await response.blob();
    const objectUrl = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = objectUrl;
    link.download = file?.name || "file";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(objectUrl);
  } catch {
    const link = document.createElement("a");
    link.href = url;
    link.download = file?.name || "file";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  }
}
function handleBack() {
  router.back();
}
async function getDetail() {
  if (!workOrderId.value) return;
  startLoading();
  try {
    const res = await fetchWorkOrderDetail(workOrderId.value);
    if (res.code === 200 && res.data) {
      detailData.value = res.data;
    } else {
      detailData.value = {};
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getDetail();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <div class="flex items-center gap-2 mb-3">
        <div
          class="bg-[#0068FF]"
          style="width: 3px; height: 14px; border-radius: 3px"
        ></div>
        <div
          class="text-[#171F26]"
          style="line-height: 24px; font-size: 16px; font-weight: bold"
        >
          工单详情
        </div>
      </div>
      <n-spin :show="loading">
        <div class="grid grid-cols-2 gap-4 text-sm text-[#1D2129]">
          <div>问题:{{ detailData.problem || "-" }}</div>
          <div>反馈用户:{{ detailData.feedbackUserPhone || "-" }}</div>
          <div>来源:{{ displayProblemSource }}</div>
          <div>类型:{{ displayType }}</div>
          <div>优先级:{{ displayPriority }}</div>
          <div>创建人:{{ detailData.createBy }}</div>
          <div>创建时间:{{ detailData.createTime || "-" }}</div>
        </div>
        <div class="mt-3 flex text-sm text-[#1D2129]">
          <div class="shrink-0">问题描述:</div>
          <div class="whitespace-pre-wrap text-[#4E5969]">
            {{ detailData.problemDescription || "-" }}
          </div>
        </div>
        <div v-if="displayImages.length" class="mt-3">
          <n-image-group>
            <div class="flex flex-wrap gap-2">
              <n-image
                v-for="item in displayImages"
                :key="item.id || item.url"
                :src="item.url"
                width="80"
                height="80"
              />
            </div>
          </n-image-group>
        </div>
      </n-spin>
    </n-card>
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <div class="flex items-center gap-2 mb-3">
        <div
          class="bg-[#0068FF]"
          style="width: 3px; height: 14px; border-radius: 3px"
        ></div>
        <div
          class="text-[#171F26]"
          style="line-height: 24px; font-size: 16px; font-weight: bold"
        >
          处理流程
        </div>
      </div>
      <n-spin :show="loading">
        <n-timeline>
          <n-timeline-item
            v-for="(item, index) in timelineItems"
            :key="index"
            :title="item.title"
            type="success"
          >
            <div>
              <div class="flex items-center gap-10">
                <!-- 增加一个头像 -->
                <div class="flex items-center gap-3">
                  <n-avatar :src="item.handleUserAvatar" size="small" />
                  <div>{{ item.handleUserName || "-" }}</div>
                </div>
                <div>{{ item.typeTxt || "-" }}</div>
              </div>
              <div
                v-if="index != 0"
                class="mt-2 rounded border border-[#E5E6EB] bg-[#F7F8FA] p-3 text-sm text-[#1D2129]"
              >
                <div class="flex">
                  <div class="shrink-0">处理内容:</div>
                  <div>
                    <div>{{ item.log.handleLog || "-" }}</div>
                    <div class="mt-2">
                      <div
                        v-if="parseFileList(item.log.handleFile).length"
                        class="mt-2 space-y-2"
                      >
                        <div
                          v-for="file in parseFileList(item.log.handleFile)"
                          :key="file.id || file.url"
                          class="flex items-center gap-3"
                        >
                          <div class="max-w-[320px] truncate">
                            {{ file.name || "-" }}
                          </div>
                          <n-button
                            type="primary"
                            :text="true"
                            @click="handlePreview(file)"
                          >
                            预览
                          </n-button>
                          <n-button
                            type="primary"
                            :text="true"
                            @click="handleDownload(file)"
                          >
                            下载
                          </n-button>
                        </div>
                      </div>
                      <div v-else class="text-[#4E5969]">-</div>
                    </div>
                  </div>
                </div>
                <div class="mt-2">
                  操作类型:
                  <n-tag type="primary">{{
                    handleTypeLabel(item.log.handleType)
                  }}</n-tag>
                </div>
              </div>
            </div>
          </n-timeline-item>
        </n-timeline>
      </n-spin>
      <div class="mt-5 flex justify-center">
        <n-button @click="handleBack">返回</n-button>
      </div>
    </n-card>
  </div>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/index.vue
New file
@@ -0,0 +1,564 @@
<script setup lang="ts">
import { ref, h, onMounted } from "vue";
import type { DataTableColumns } from "naive-ui";
import { NButton } from "naive-ui";
import { useRouter } from "vue-router";
import { useBoolean } from "@/hooks";
import {
  fetchWorkOrderPageList,
  fetchFeedbackTypeList,
  fetchOperationsUserListAll,
  saveWorkOrder,
  updateWorkOrder,
  deleteWorkOrder,
  handleWorkOrder,
} from "@/service/api/serviceCenter";
import WorkOrderModal from "./components/WorkOrderModal.vue";
import WorkOrderHandleModal from "./components/WorkOrderHandleModal.vue";
interface WorkOrderItem {
  id: number;
  feedbackUserPhone?: string;
  problemSource?: number;
  feedbackTypeId?: number;
  feedbackTypeName?: string;
  problem?: string;
  problemDescription?: string;
  problemImage?: string;
  priority?: number;
  handleStatus?: number;
  handleUserId?: number | string;
  handleUserName?: string;
  createTime?: string;
}
const {
  bool: loading,
  setTrue: startLoading,
  setFalse: endLoading,
} = useBoolean(false);
const router = useRouter();
const initialSearchForm = {
  feedbackUserPhone: "",
  problemSource: null as number | null,
  feedbackTypeId: null as number | null,
  priority: null as number | null,
  handleStatus: null as number | null,
};
const searchForm = ref({ ...initialSearchForm });
const tableData = ref<WorkOrderItem[]>([]);
const currentRow = ref<WorkOrderItem | null>(null);
const modalVisible = ref(false);
const modalMode = ref<"add" | "edit">("add");
const handleModalVisible = ref(false);
const handleRow = ref<WorkOrderItem | null>(null);
const pagination = ref({
  page: 1,
  pageSize: 10,
  itemCount: 0,
  showSizePicker: true,
  pageSizes: [10, 20, 50, 100],
});
const typeOptions = ref<{ label: string; value: number }[]>([]);
const handleUserOptions = ref<{ label: string; value: number | string }[]>([]);
const problemSourceOptions = [{ label: "电话客服", value: 1 }];
const priorityOptions = [
  { label: "低", value: 1 },
  { label: "中", value: 2 },
  { label: "高", value: 3 },
];
const statusOptions = [
  { label: "待处理", value: 1 },
  { label: "处理中", value: 2 },
  { label: "已处理", value: 3 },
];
const priorityLabelMap: Record<number, string> = {
  1: "低",
  2: "中",
  3: "高",
};
const statusLabelMap: Record<number, string> = {
  1: "待处理",
  2: "处理中",
  3: "已处理",
};
const problemSourceLabelMap: Record<number, string> = {
  1: "电话客服",
};
const columns: DataTableColumns<WorkOrderItem> = [
  {
    title: "序号",
    key: "index",
    width: 80,
    align: "center",
    render: (_row, index) =>
      h(
        "span",
        (pagination.value.page - 1) * pagination.value.pageSize + index + 1
      ),
  },
  {
    title: "提交用户",
    key: "feedbackUserPhone",
    width: 140,
    align: "center",
    render: (row) => row.feedbackUserPhone || "-",
  },
  {
    title: "来源",
    key: "problemSource",
    width: 120,
    align: "center",
    render: (row) => problemSourceLabelMap[row.problemSource || 0] || "-",
  },
  {
    title: "类型",
    key: "feedbackTypeName",
    width: 140,
    align: "center",
    render: (row) => row.feedbackTypeName || "-",
  },
  {
    title: "问题",
    key: "problem",
    minWidth: 200,
    align: "center",
    render: (row) => row.problem || "-",
  },
  {
    title: "优先级",
    key: "priority",
    width: 120,
    align: "center",
    render: (row) => priorityLabelMap[row.priority || 0] || "-",
  },
  {
    title: "处理状态",
    key: "handleStatus",
    width: 120,
    align: "center",
    render: (row) => statusLabelMap[row.handleStatus || 0] || "-",
  },
  {
    title: "处理人",
    key: "handleUserName",
    width: 120,
    align: "center",
    render: (row) => row.handleUserName || "-",
  },
  {
    title: "添加时间",
    key: "createTime",
    width: 180,
    align: "center",
    render: (row) => row.createTime || "-",
  },
  {
    title: "操作",
    key: "actions",
    width: 260,
    align: "center",
    render: (row) => {
      const buttons = [
        h(
          NButton,
          { text: true, type: "primary", onClick: () => handleView(row) },
          { default: () => "详情" }
        ),
      ];
      if (row.handleStatus === 1) {
        buttons.push(
          h(
            NButton,
            { text: true, type: "primary", onClick: () => handleStart(row) },
            { default: () => "开始处理" }
          )
        );
      }
      if (row.handleStatus === 2) {
        buttons.push(
          h(
            NButton,
            { text: true, type: "primary", onClick: () => handleProcess(row) },
            { default: () => "处理" }
          )
        );
      }
      buttons.push(
        h(
          NButton,
          { text: true, type: "primary", onClick: () => handleEdit(row) },
          { default: () => "编辑" }
        ),
        h(
          NButton,
          { text: true, type: "error", onClick: () => handleDelete(row) },
          { default: () => "删除" }
        )
      );
      return h(
        "div",
        { class: "flex items-center justify-center gap-2" },
        buttons
      );
    },
  },
];
function handleSearch() {
  pagination.value.page = 1;
  getWorkOrderList();
}
function handleReset() {
  searchForm.value = { ...initialSearchForm };
  pagination.value.page = 1;
  getWorkOrderList();
}
function handlePageChange(page: number) {
  pagination.value.page = page;
  getWorkOrderList();
}
function handlePageSizeChange(pageSize: number) {
  pagination.value.pageSize = pageSize;
  pagination.value.page = 1;
  getWorkOrderList();
}
function handleCreate() {
  modalMode.value = "add";
  currentRow.value = null;
  modalVisible.value = true;
}
function handleEdit(row: WorkOrderItem) {
  modalMode.value = "edit";
  currentRow.value = row;
  modalVisible.value = true;
}
function handleView(row: WorkOrderItem) {
  router.push({
    path: "/service/ticket/workOrder/detail",
    query: { id: row.id },
  });
}
function handleStart(row: WorkOrderItem) {
  handleRow.value = row;
  handleModalVisible.value = true;
}
function handleProcess(row: WorkOrderItem) {
  handleRow.value = row;
  handleModalVisible.value = true;
}
async function handleSave(formData: any) {
  const handleUser = handleUserOptions.value.find(
    (item) => item.value === formData.handleUserId
  );
  const savePayload = {
    feedbackTypeId: formData.feedbackTypeId || undefined,
    feedbackUserPhone: formData.feedbackUserPhone || undefined,
    priority: formData.priority ?? undefined,
    problemSource: formData.problemSource ?? undefined,
    problem: formData.problem || undefined,
    problemDescription: formData.problemDescription || undefined,
    problemImage: Array.isArray(formData.problemImage)
      ? formData.problemImage
      : undefined,
    handleUserId: formData.handleUserId ?? undefined,
    handleUserName: handleUser?.label || undefined,
  };
  if (modalMode.value === "add") {
    const { code } = await saveWorkOrder(savePayload);
    if (code === 200) {
      window.$message?.success("新增成功");
      modalVisible.value = false;
      getWorkOrderList();
    } else {
      window.$message?.error("新增失败");
    }
    return;
  }
  const { code } = await updateWorkOrder({
    id: formData.id as number,
    ...savePayload,
  });
  if (code === 200) {
    window.$message?.success("编辑成功");
    modalVisible.value = false;
    getWorkOrderList();
  } else {
    window.$message?.error("编辑失败");
  }
}
async function handleSubmitHandle(payload: {
  handleType: number;
  handleLog: string;
  handleUserId: number | string | null;
  handleFile: { id: string; name: string; url: string }[];
}) {
  if (!handleRow.value?.id) return;
  const selectedUser = handleUserOptions.value.find(
    (item) => item.value === payload.handleUserId
  );
  const handleUserId =
    payload.handleUserId ?? handleRow.value.handleUserId ?? undefined;
  const handleUserName =
    selectedUser?.label || handleRow.value.handleUserName || undefined;
  const submitData = {
    handleFile: payload.handleFile || undefined,
    handleLog: payload.handleLog,
    handleType: payload.handleType,
    handleUserId,
    handleUserName,
    workOrderId: handleRow.value.id,
  };
  const execSubmit = async () => {
    const { code } = await handleWorkOrder(submitData);
    if (code === 200) {
      window.$message?.success("操作成功");
      handleModalVisible.value = false;
      getWorkOrderList();
    } else {
      window.$message?.error("操作失败");
    }
  };
  if (payload.handleType === 2) {
    window.$dialog?.warning({
      title: "确认结办",
      content: "是否确认结办?",
      positiveText: "确定",
      negativeText: "取消",
      onPositiveClick: execSubmit,
    });
    return;
  }
  execSubmit();
}
function handleDelete(row: WorkOrderItem) {
  window.$dialog?.warning({
    title: "确认删除",
    content: "确定要删除该工单吗?",
    positiveText: "确定",
    negativeText: "取消",
    onPositiveClick: async () => {
      const { error } = await deleteWorkOrder(row.id);
      if (!error) {
        window.$message?.success("删除成功");
        getWorkOrderList();
      } else {
        window.$message?.error("删除失败");
      }
    },
  });
}
async function getTypeOptions() {
  const res = await fetchFeedbackTypeList({ pageNum: 1, pageSize: 9999 });
  if (res.code === 200 && res.data) {
    const list = Array.isArray(res.data) ? res.data : res.data.records || [];
    typeOptions.value = list.map((item: any) => ({
      label: item.feedbackType,
      value: item.id,
    }));
  } else {
    typeOptions.value = [];
  }
}
async function getHandleUserOptions() {
  const res = await fetchOperationsUserListAll();
  if (res.code === 200 && res.data) {
    const list = Array.isArray(res.data) ? res.data : res.data.records || [];
    handleUserOptions.value = list
      .filter((item: any) => item.nickName)
      .map((item: any) => ({
        label: item.nickName,
        value: item.id,
      }));
  } else {
    handleUserOptions.value = [];
  }
}
async function getWorkOrderList() {
  startLoading();
  try {
    const res = await fetchWorkOrderPageList({
      feedbackTypeId: searchForm.value.feedbackTypeId || undefined,
      feedbackUserPhone: searchForm.value.feedbackUserPhone || undefined,
      handleStatus: searchForm.value.handleStatus ?? undefined,
      pageNum: pagination.value.page,
      pageSize: pagination.value.pageSize,
      priority: searchForm.value.priority ?? undefined,
      problemSource: searchForm.value.problemSource ?? undefined,
    });
    if (res.code === 200 && res.data) {
      tableData.value = res.data.records || [];
      pagination.value.itemCount = res.data.total || 0;
    } else {
      tableData.value = [];
      pagination.value.itemCount = 0;
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getTypeOptions();
  getHandleUserOptions();
  getWorkOrderList();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form
        :model="searchForm"
        label-placement="left"
        inline
        :show-feedback="false"
      >
        <div class="flex justify-between items-center w-full">
          <div class="flex flex-wrap gap-4">
            <n-form-item label="用户">
              <n-input
                v-model:value="searchForm.feedbackUserPhone"
                placeholder="请输入"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="问题来源">
              <n-select
                v-model:value="searchForm.problemSource"
                :options="problemSourceOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="类型">
              <n-select
                v-model:value="searchForm.feedbackTypeId"
                :options="typeOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="优先级">
              <n-select
                v-model:value="searchForm.priority"
                :options="priorityOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="状态">
              <n-select
                v-model:value="searchForm.handleStatus"
                :options="statusOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
          </div>
          <div class="flex gap-2">
            <n-button style="width: 84px; height: 32px" @click="handleReset"
              >重置</n-button
            >
            <n-button
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
              >查询</n-button
            >
          </div>
        </div>
      </n-form>
    </n-card>
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <div class="mb-4 flex justify-end gap-2">
        <n-button
          style="
            width: 96px;
            height: 32px;
            color: #0068ff;
            background-color: #e6f4ff;
            border: 1px solid #7abdff;
          "
          @click="handleCreate"
          >创建工单</n-button
        >
      </div>
      <n-data-table
        :columns="columns"
        :data="tableData"
        :loading="loading"
        :pagination="{
          page: pagination.page,
          pageSize: pagination.pageSize,
          itemCount: pagination.itemCount,
          showSizePicker: true,
          pageSizes: pagination.pageSizes,
          prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`,
        }"
        :scroll-x="1600"
        :row-key="(row: WorkOrderItem) => row.id"
        @update:page="handlePageChange"
        @update:page-size="handlePageSizeChange"
      />
    </n-card>
    <WorkOrderModal
      v-model:visible="modalVisible"
      :mode="modalMode"
      :row-data="currentRow"
      :type-options="typeOptions"
      :problem-source-options="problemSourceOptions"
      :priority-options="priorityOptions"
      :handle-user-options="handleUserOptions"
      @submit="handleSave"
    />
    <WorkOrderHandleModal
      v-model:visible="handleModalVisible"
      :row-data="handleRow"
      :type-options="typeOptions"
      :problem-source-options="problemSourceOptions"
      :priority-options="priorityOptions"
      :handle-user-options="handleUserOptions"
      @submit="handleSubmitHandle"
    />
  </div>
</template>
<style scoped></style>