feat(客服工单): 新增客服工单管理模块
- 添加静态路由配置,包括客服工单、意见反馈、反馈类型和工单详情页面
- 新增API接口函数,支持反馈列表、反馈类型CRUD和工单管理操作
- 实现反馈类型管理页面,支持增删改查操作
- 实现意见反馈列表页面,支持查询和图片预览功能
- 实现工单管理页面,支持创建、编辑、删除、处理和查看详情功能
- 添加工单详情页面,展示处理流程和文件管理功能
- 新增工单创建/编辑弹窗组件,支持表单验证和图片上传
- 新增工单处理弹窗组件,支持处理日志记录和文件上传
| | |
| | | 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: '人员资质审核', |
| | |
| | | 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', |
| | | }, |
| | | |
| | | ] |
| | |
| | | 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) |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |