feat(工单管理): 优化工单处理界面及上传组件功能
- 添加工单处理模态框的拖拽上传功能,支持图片、音频、视频等多种文件格式
- 为所有下拉选择器添加自定义箭头图标,提升视觉一致性
- 优化工单列表的展示样式,使用标签显示优先级和处理状态
- 修复文件上传组件中ID赋值问题,避免破坏Naive UI内部追踪逻辑
- 新增多个公共图标资源,用于文件类型展示和操作按钮
- 统一工单创建和编辑界面的布局和文案,改善用户体验
| | |
| | | const res = await uploadFile(formData) |
| | | if (res.code === 200) { |
| | | file.url = res.data.url |
| | | file.id = res.data.id |
| | | // 不要修改 file.id,以免破坏 Naive UI 的内部追踪逻辑 |
| | | file.status = 'finished' |
| | | onFinish() |
| | | return { |
| | | id: file.id, |
| | | url: file.url, |
| | | id: res.data.id, // 返回后端 ID 给组件使用 |
| | | url: res.data.url, |
| | | name: file.name, |
| | | status: 'finished', |
| | | } |
| | |
| | | |
| | | const rules: FormRules = { |
| | | result: [ |
| | | { required: true, message: t("serviceReview.common.chooseResult"), trigger: "change" }, |
| | | { |
| | | required: true, |
| | | message: t("serviceReview.common.chooseResult"), |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | auditRemarks: [ |
| | | { |
| | |
| | | label-placement="left" |
| | | require-mark-placement="left" |
| | | > |
| | | <n-form-item :label="t('serviceReview.common.auditResult')" path="result" required> |
| | | <n-form-item |
| | | :label="t('serviceReview.common.auditResult')" |
| | | path="result" |
| | | required |
| | | > |
| | | <n-radio-group v-model:value="formModel.result"> |
| | | <n-radio value="pass">{{ t("serviceReview.common.pass") }}</n-radio> |
| | | <n-radio value="reject">{{ t("serviceReview.common.reject") }}</n-radio> |
| | | <n-radio value="reject">{{ |
| | | t("serviceReview.common.reject") |
| | | }}</n-radio> |
| | | </n-radio-group> |
| | | </n-form-item> |
| | | |
| | |
| | | </n-modal> |
| | | </template> |
| | | |
| | | <style scoped></style> |
| | | <style scoped> |
| | | </style> |
| | |
| | | getPersonnelList() |
| | | }) |
| | | |
| | | const isRoleSelectOpen = ref(false); |
| | | const isAuditStatusSelectOpen = ref(false); |
| | | |
| | | const filterItems = computed(() => [ |
| | | { |
| | | label: t('serviceReview.personnel.searchName'), |
| | |
| | | { |
| | | label: t('serviceReview.personnel.role'), |
| | | path: 'roleId', |
| | | render: () => <n-select options={roleOptions.value} v-model:value={searchForm.value.roleId} placeholder={t('common.selectPlaceholder')} clearable />, |
| | | render: () => ( |
| | | <n-select |
| | | options={roleOptions.value} |
| | | v-model:value={searchForm.value.roleId} |
| | | placeholder={t('common.selectPlaceholder')} |
| | | clearable |
| | | onUpdate:show={val => isRoleSelectOpen.value = val} |
| | | > |
| | | {{ |
| | | arrow: () => ( |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | class={`arrow-icon ${isRoleSelectOpen.value ? 'arrow-icon--open' : ''}`} |
| | | style={{ width: '16px', height: '16px' }} |
| | | alt="arrow" |
| | | /> |
| | | ), |
| | | }} |
| | | </n-select> |
| | | ), |
| | | }, |
| | | { |
| | | label: t('serviceReview.policyApplication.status'), |
| | | path: 'auditStatus', |
| | | render: () => <n-select options={statusOptions} v-model:value={searchForm.value.auditStatus} placeholder={t('common.selectPlaceholder')} clearable />, |
| | | render: () => ( |
| | | <n-select |
| | | options={statusOptions} |
| | | v-model:value={searchForm.value.auditStatus} |
| | | placeholder={t('common.selectPlaceholder')} |
| | | clearable |
| | | onUpdate:show={val => isAuditStatusSelectOpen.value = val} |
| | | > |
| | | {{ |
| | | arrow: () => ( |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | class={`arrow-icon ${isAuditStatusSelectOpen.value ? 'arrow-icon--open' : ''}`} |
| | | style={{ width: '16px', height: '16px' }} |
| | | alt="arrow" |
| | | /> |
| | | ), |
| | | }} |
| | | </n-select> |
| | | ), |
| | | }, |
| | | ]) |
| | | </script> |
| | |
| | | getPolicyApplicationList(); |
| | | }); |
| | | |
| | | const isAuditStatusSelectOpen = ref(false); |
| | | |
| | | const filterItems = computed(() => [ |
| | | { |
| | | label: t("serviceReview.policyApplication.plateNumber"), |
| | |
| | | { |
| | | label: t("serviceReview.policyApplication.status"), |
| | | path: 'auditStatus', |
| | | render: () => <n-select options={statusOptions} v-model:value={searchForm.value.auditStatus} placeholder={t("common.selectPlaceholder")} clearable />, |
| | | render: () => ( |
| | | <n-select |
| | | options={statusOptions} |
| | | v-model:value={searchForm.value.auditStatus} |
| | | placeholder={t("common.selectPlaceholder")} |
| | | clearable |
| | | onUpdate:show={val => isAuditStatusSelectOpen.value = val} |
| | | > |
| | | {{ |
| | | arrow: () => ( |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | class={`arrow-icon ${isAuditStatusSelectOpen.value ? 'arrow-icon--open' : ''}`} |
| | | style={{ width: '16px', height: '16px' }} |
| | | alt="arrow" |
| | | /> |
| | | ), |
| | | }} |
| | | </n-select> |
| | | ), |
| | | }, |
| | | { |
| | | label: t("serviceReview.policyApplication.applyTime"), |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref, watch } from "vue"; |
| | | import type { FormInst, FormRules, UploadFileInfo } from "naive-ui"; |
| | | import { computed, nextTick, ref, watch } from "vue"; |
| | | import type { FormInst, FormRules, UploadFileInfo, UploadInst } from "naive-ui"; |
| | | import { customUploadRequest } from "@/utils/upload"; |
| | | |
| | | interface HandleFileItem { |
| | |
| | | const formModel = ref({ |
| | | handleLog: "", |
| | | handleUserId: null as number | string | null, |
| | | handleFile: [] as HandleFileItem[], |
| | | handleFile: [] as UploadFileInfo[], |
| | | }); |
| | | const fileList = ref<UploadFileInfo[]>([]); |
| | | const uploadRef = ref<UploadInst | null>(null); |
| | | |
| | | const isHandleUserSelectOpen = ref(false); |
| | | |
| | | const rules: FormRules = { |
| | | handleLog: [{ required: true, message: "请输入处理情况", trigger: "blur" }], |
| | | handleFile: [ |
| | | { |
| | | required: true, |
| | | validator(_rule, value: UploadFileInfo[]) { |
| | | return value && value.some((f) => f.status === "finished"); |
| | | }, |
| | | message: "请上传相关资料", |
| | | trigger: ["change", "blur"], |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | const disableSave = computed(() => !formModel.value.handleUserId); |
| | |
| | | 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(); |
| | | try { |
| | | const res = await customUploadRequest({ |
| | | file, |
| | | onFinish: () => {}, |
| | | onError, |
| | | onProgress, |
| | | }); |
| | | if (res) { |
| | | // 保持 file.id 不变,供 Naive UI 内部追踪 |
| | | file.url = res.url; |
| | | // 存储后端返回的真正的 ID 到自定义属性上 |
| | | (file as any).serverId = String(res.id); |
| | | // 显式标记完成 |
| | | file.status = "finished"; |
| | | |
| | | // 调用 Naive UI 的完成回调 |
| | | onFinish(); |
| | | |
| | | // 触发视图刷新 |
| | | formModel.value.handleFile = [...formModel.value.handleFile]; |
| | | } else { |
| | | onError(); |
| | | } |
| | | } catch (err) { |
| | | onError(); |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| | | 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)) |
| | | ); |
| | | _fileListValue: UploadFileInfo[] |
| | | ) {} |
| | | |
| | | function getFileIcon(file: UploadFileInfo) { |
| | | if (file.status === "error") return "/src/assets/public/error-icon.png"; |
| | | const name = file.name.toLowerCase(); |
| | | if (name.endsWith(".ppt") || name.endsWith(".pptx")) |
| | | return "/src/assets/public/ppt-icon.png"; |
| | | if (name.endsWith(".doc") || name.endsWith(".docx") || name.endsWith(".pdf")) |
| | | return "/src/assets/public/word-icon.png"; |
| | | |
| | | const imgExts = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]; |
| | | if (imgExts.some((ext) => name.endsWith(ext))) |
| | | return "/src/assets/public/img-icon.png"; |
| | | |
| | | return "/src/assets/public/img-icon.png"; |
| | | } |
| | | |
| | | function formatFileSize(bytes?: number) { |
| | | if (!bytes) return ""; |
| | | if (bytes < 1024) return bytes + "B"; |
| | | if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + "KB"; |
| | | return (bytes / 1024 / 1024).toFixed(1) + "MB"; |
| | | } |
| | | |
| | | function handleDownload(file: UploadFileInfo) { |
| | | if (file.url) { |
| | | window.open(file.url); |
| | | } |
| | | } |
| | | |
| | | function handleRetry(file: UploadFileInfo) { |
| | | uploadRef.value?.submit(file.id); |
| | | } |
| | | |
| | | function removeFile(file: UploadFileInfo) { |
| | | const index = formModel.value.handleFile.findIndex((f) => f.id === file.id); |
| | | if (index !== -1) { |
| | | formModel.value.handleFile.splice(index, 1); |
| | | } |
| | | } |
| | | |
| | | async function handleSave() { |
| | | await formRef.value?.validate(); |
| | | |
| | | const files = formModel.value.handleFile |
| | | .filter((f) => f.status === "finished" && (f as any).serverId) |
| | | .map((f) => ({ |
| | | id: (f as any).serverId, |
| | | name: f.name, |
| | | url: f.url || "", |
| | | })); |
| | | |
| | | emit("submit", { |
| | | handleType: 1, |
| | | handleLog: formModel.value.handleLog, |
| | | handleUserId: formModel.value.handleUserId, |
| | | handleFile: JSON.stringify(formModel.value.handleFile), |
| | | handleFile: JSON.stringify(files), |
| | | }); |
| | | } |
| | | |
| | | async function handleClose() { |
| | | await formRef.value?.validate(); |
| | | |
| | | const files = formModel.value.handleFile |
| | | .filter((f) => f.status === "finished" && (f as any).serverId) |
| | | .map((f) => ({ |
| | | id: (f as any).serverId, |
| | | name: f.name, |
| | | url: f.url || "", |
| | | })); |
| | | |
| | | emit("submit", { |
| | | handleType: 2, |
| | | handleLog: formModel.value.handleLog, |
| | | handleUserId: null, |
| | | handleFile: JSON.stringify(formModel.value.handleFile), |
| | | handleFile: JSON.stringify(files), |
| | | }); |
| | | } |
| | | |
| | |
| | | preset="card" |
| | | title="处理工单" |
| | | :mask-closable="false" |
| | | :style="{ width: '860px' }" |
| | | :style="{ width: '600px' }" |
| | | :auto-focus="false" |
| | | :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 class="space-y-10"> |
| | | <div class="space-y-5"> |
| | | <n-flex :size="[8, 0]" align="center" class="mb-2"> |
| | | <div class="bg-[#0068FF] w-3px h-14px"></div> |
| | | <div class="text-[#171F26] text-sm font-medium">基础信息</div> |
| | | </n-flex> |
| | | <div class="space-y-4"> |
| | | <!-- 问题 --> |
| | | <div> |
| | | <div class="whitespace-pre-wrap text-[#4E5969]"> |
| | | <div class="text-[rgba(23,31,38,0.6)] text-sm mb-1">问题</div> |
| | | <div class="text-[#171F26] text-sm"> |
| | | {{ props.rowData?.problem || "-" }} |
| | | </div> |
| | | </div> |
| | | <!-- 问题描述 --> |
| | | <div> |
| | | <div class="text-[rgba(23,31,38,0.6)] text-sm mb-1">问题描述</div> |
| | | <div class="text-[#171F26] text-sm whitespace-pre-wrap"> |
| | | {{ props.rowData?.problemDescription || "-" }} |
| | | </div> |
| | | <!-- 图片列表 --> |
| | | <div v-if="displayImages.length" class="mt-3"> |
| | | <n-image-group> |
| | | <div class="flex flex-wrap gap-2"> |
| | |
| | | v-for="item in displayImages" |
| | | :key="item.id || item.url" |
| | | :src="item.url" |
| | | width="80" |
| | | height="80" |
| | | width="100" |
| | | height="100" |
| | | class="rounded-lg" |
| | | /> |
| | | </div> |
| | | </n-image-group> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 网格信息 --> |
| | | <div class="grid grid-cols-2 gap-y-4"> |
| | | <div> |
| | | <div class="text-[rgba(23,31,38,0.6)] text-sm mb-1">反馈用户</div> |
| | | <div class="text-[#171F26] text-sm"> |
| | | {{ props.rowData?.feedbackUserPhone || "-" }} |
| | | </div> |
| | | </div> |
| | | <div> |
| | | <div class="text-[rgba(23,31,38,0.6)] text-sm mb-1">来源</div> |
| | | <div class="text-[#171F26] text-sm"> |
| | | {{ displayProblemSource }} |
| | | </div> |
| | | </div> |
| | | <div> |
| | | <div class="text-[rgba(23,31,38,0.6)] text-sm mb-1">类型</div> |
| | | <div class="text-[#171F26] text-sm">{{ displayType }}</div> |
| | | </div> |
| | | <div> |
| | | <div class="text-[rgba(23,31,38,0.6)] text-sm mb-1">优先级</div> |
| | | <div> |
| | | <n-tag |
| | | v-if="props.rowData?.priority" |
| | | :type=" |
| | | props.rowData.priority === 3 |
| | | ? 'error' |
| | | : props.rowData.priority === 2 |
| | | ? 'warning' |
| | | : 'success' |
| | | " |
| | | :bordered="false" |
| | | size="small" |
| | | > |
| | | {{ displayPriority }} |
| | | </n-tag> |
| | | <span v-else class="text-[#171F26] text-sm">-</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | ref="formRef" |
| | | :model="formModel" |
| | | :rules="rules" |
| | | label-placement="left" |
| | | label-placement="top" |
| | | label-width="90" |
| | | require-mark-placement="left" |
| | | > |
| | | <div class="text-sm font-medium text-[#1D2129] mb-2">处理日志</div> |
| | | <n-flex :size="[8, 0]" align="center" class="mb-4"> |
| | | <div class="bg-[#0068FF] w-3px h-14px"></div> |
| | | <div class="text-[#171F26] text-sm font-medium">处理日志</div> |
| | | </n-flex> |
| | | <n-form-item label="处理情况" path="handleLog"> |
| | | <n-input |
| | | v-model:value="formModel.handleLog" |
| | |
| | | clearable |
| | | /> |
| | | </n-form-item> |
| | | <n-form-item label="相关资料" path="handleFile" :show-feedback="false"> |
| | | <n-form-item path="handleFile"> |
| | | <template #label> |
| | | <n-flex> |
| | | <div>相关资料</div> |
| | | <div style="color: rgba(23, 31, 38, 0.6)"> |
| | | 上传通话录音、处理结果等资料 |
| | | </div> |
| | | </n-flex> |
| | | </template> |
| | | <div class="w-full"> |
| | | <n-upload |
| | | v-model:file-list="fileList" |
| | | list-type="text" |
| | | ref="uploadRef" |
| | | v-model:file-list="formModel.handleFile" |
| | | :show-file-list="false" |
| | | :max="10" |
| | | accept="image/*,audio/*,video/*" |
| | | accept="image/*,audio/*,video/*,.doc,.docx,.ppt,.pptx,.pdf" |
| | | :custom-request="handleUploadFile" |
| | | @remove="handleRemoveFile" |
| | | @on-remove="handleRemoveFile" |
| | | directory-dnd |
| | | > |
| | | <n-button>上传文件</n-button> |
| | | <n-upload-dragger> |
| | | <n-flex justify="center"> |
| | | <img |
| | | src="/src/assets/public/upload-folder-icon.png" |
| | | class="w-32px h-32px" |
| | | /> |
| | | </n-flex> |
| | | <n-text style="font-size: 16px; margin-top: 18px"> |
| | | 点击或拖拽文件到此处上传 |
| | | </n-text> |
| | | <n-p |
| | | depth="3" |
| | | style="margin-top: 12px; color: rgba(23, 31, 38, 0.6)" |
| | | > |
| | | 支持图片、音频、视频等文件,大小≤60M |
| | | </n-p> |
| | | </n-upload-dragger> |
| | | </n-upload> |
| | | <div class="mt-2 text-xs text-[#86909C]"> |
| | | 上传通话录音、处理结果等资料。上传文件类型可选图片、音频、视频 |
| | | |
| | | <div v-if="formModel.handleFile.length" class="mt-4 flex flex-col gap-3 w-full"> |
| | | <div |
| | | v-for="file in formModel.handleFile" |
| | | :key="file.id" |
| | | class="relative flex items-center justify-between p-3 rounded bg-[#F4F6F8] overflow-hidden" |
| | | > |
| | | <div |
| | | v-if="file.status === 'uploading' || file.status === 'error'" |
| | | class="absolute bottom-0 left-0 h-[2px] transition-all duration-300" |
| | | :class=" |
| | | file.status === 'error' ? 'bg-[#F53F3F]' : 'bg-[#165DFF]' |
| | | " |
| | | :style="{ width: `${file.percentage || 0}%` }" |
| | | ></div> |
| | | |
| | | <div class="flex items-center gap-3 z-10"> |
| | | <img |
| | | :src="getFileIcon(file)" |
| | | class="w-6 h-6 object-contain" |
| | | /> |
| | | <div class="flex items-center gap-3"> |
| | | <span |
| | | v-if="file.status === 'error'" |
| | | class="text-[#F53F3F] text-sm" |
| | | >上传失败</span |
| | | > |
| | | <span |
| | | v-else-if="file.status === 'uploading'" |
| | | class="text-[#165DFF] text-sm" |
| | | >上传中</span |
| | | > |
| | | <template v-else> |
| | | <span |
| | | class="text-[#1D2129] text-sm truncate max-w-[200px]" |
| | | >{{ file.name }}</span |
| | | > |
| | | <span class="text-[#86909C] text-sm">{{ |
| | | formatFileSize(file.file?.size) |
| | | }}</span> |
| | | </template> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex items-center gap-4 pr-1 z-10"> |
| | | <img |
| | | src="/src/assets/public/delete-file-icon.png" |
| | | class="w-11px h-13px cursor-pointer hover:opacity-80" |
| | | @click="removeFile(file)" |
| | | /> |
| | | <img |
| | | v-if="file.status === 'error'" |
| | | src="/src/assets/public/reload-upload-icon.png" |
| | | class="w-12px h-12px cursor-pointer hover:opacity-80" |
| | | @click="handleRetry(file)" |
| | | /> |
| | | <img |
| | | v-if="file.status === 'finished'" |
| | | src="/src/assets/public/download-file-icon.png" |
| | | class="w-12px h-12px cursor-pointer hover:opacity-80" |
| | | @click="handleDownload(file)" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </n-form-item> |
| | |
| | | :options="handleUserOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | /> |
| | | @update:show="(val) => (isHandleUserSelectOpen = val)" |
| | | > |
| | | <template #arrow> |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | :class="[ |
| | | 'arrow-icon', |
| | | isHandleUserSelectOpen ? 'arrow-icon--open' : '', |
| | | ]" |
| | | style="width: 16px; height: 16px" |
| | | alt="arrow" |
| | | /> |
| | | </template> |
| | | </n-select> |
| | | </n-form-item> |
| | | </n-form> |
| | | </div> |
| | |
| | | </n-modal> |
| | | </template> |
| | | |
| | | <style scoped></style> |
| | | <style scoped lang="scss"> |
| | | ::v-deep .n-upload-dragger { |
| | | padding: 40px 0; |
| | | background-color: transparent; |
| | | } |
| | | </style> |
| | |
| | | }); |
| | | const fileList = ref<UploadFileInfo[]>([]); |
| | | |
| | | const isProblemSourceSelectOpen = ref(false); |
| | | const isFeedbackTypeSelectOpen = ref(false); |
| | | const isPrioritySelectOpen = ref(false); |
| | | const isHandleUserSelectOpen = ref(false); |
| | | |
| | | const rules: FormRules = { |
| | | problem: [{ required: true, message: "请输入问题", trigger: "blur" }], |
| | | problemDescription: [ |
| | |
| | | }; |
| | | |
| | | const modalTitle = computed(() => |
| | | props.mode === "add" ? "新增工单" : "编辑工单" |
| | | props.mode === "add" ? "创建工单" : "编辑工单" |
| | | ); |
| | | |
| | | function resetForm() { |
| | |
| | | preset="card" |
| | | :title="modalTitle" |
| | | :mask-closable="false" |
| | | :style="{ width: '720px' }" |
| | | :style="{ width: '400px' }" |
| | | :auto-focus="false" |
| | | :segmented="{ content: true, action: true }" |
| | | > |
| | | <n-form |
| | | ref="formRef" |
| | | :model="formModel" |
| | | :rules="rules" |
| | | label-placement="left" |
| | | label-placement="top" |
| | | label-width="90" |
| | | require-mark-placement="left" |
| | | > |
| | |
| | | type="textarea" |
| | | placeholder="请输入" |
| | | :rows="4" |
| | | maxlength="200" |
| | | clearable |
| | | show-count |
| | | /> |
| | | </n-form-item> |
| | | <n-form-item label="图片" path="problemImage"> |
| | |
| | | <n-upload |
| | | v-model:file-list="fileList" |
| | | list-type="image-card" |
| | | :max="10" |
| | | :max="9" |
| | | :custom-request="handleUploadImage" |
| | | @remove="handleRemoveImage" |
| | | @on-remove="handleRemoveImage" |
| | | accept="image/*" |
| | | > |
| | | <div |
| | | style=" |
| | | width: 120px; |
| | | height: 120px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | color: #999; |
| | | color: rgba(23, 31, 38, 0.6); |
| | | " |
| | | > |
| | | <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 格式 <br /> |
| | | <div |
| | | style=" |
| | | margin-top: 8px; |
| | | color: rgba(23, 31, 38, 0.6); |
| | | font-size: 12px; |
| | | display: flex; |
| | | align-items: center; |
| | | " |
| | | > |
| | | <img |
| | | src="/src/assets/public/info-icon.png" |
| | | style="width: 12px; height: 12px; margin-right: 4px" |
| | | /> |
| | | 支持jpg/png/gif ,大小≤10MB,最多上传9张 |
| | | </div> |
| | | </div> |
| | | </n-form-item> |
| | |
| | | :options="problemSourceOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | /> |
| | | @update:show="(val) => (isProblemSourceSelectOpen = val)" |
| | | > |
| | | <template #arrow> |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | :class="[ |
| | | 'arrow-icon', |
| | | isProblemSourceSelectOpen ? 'arrow-icon--open' : '', |
| | | ]" |
| | | style="width: 16px; height: 16px" |
| | | alt="arrow" |
| | | /> |
| | | </template> |
| | | </n-select> |
| | | </n-form-item> |
| | | <div class="grid grid-cols-2 gap-4"> |
| | | <n-form-item label="类型" path="feedbackTypeId" required> |
| | |
| | | :options="typeOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | /> |
| | | @update:show="(val) => (isFeedbackTypeSelectOpen = val)" |
| | | > |
| | | <template #arrow> |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | :class="[ |
| | | 'arrow-icon', |
| | | isFeedbackTypeSelectOpen ? 'arrow-icon--open' : '', |
| | | ]" |
| | | style="width: 16px; height: 16px" |
| | | alt="arrow" |
| | | /> |
| | | </template> |
| | | </n-select> |
| | | </n-form-item> |
| | | <n-form-item label="优先级" path="priority" required> |
| | | <n-select |
| | |
| | | :options="priorityOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | /> |
| | | @update:show="(val) => (isPrioritySelectOpen = val)" |
| | | > |
| | | <template #arrow> |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | :class="[ |
| | | 'arrow-icon', |
| | | isPrioritySelectOpen ? 'arrow-icon--open' : '', |
| | | ]" |
| | | style="width: 16px; height: 16px" |
| | | alt="arrow" |
| | | /> |
| | | </template> |
| | | </n-select> |
| | | </n-form-item> |
| | | </div> |
| | | <n-form-item label="处理人" path="handleUserId" required> |
| | |
| | | :options="handleUserOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | /> |
| | | @update:show="(val) => (isHandleUserSelectOpen = val)" |
| | | > |
| | | <template #arrow> |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | :class="[ |
| | | 'arrow-icon', |
| | | isHandleUserSelectOpen ? 'arrow-icon--open' : '', |
| | | ]" |
| | | style="width: 16px; height: 16px" |
| | | alt="arrow" |
| | | /> |
| | | </template> |
| | | </n-select> |
| | | </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-button type="primary" @click="handleSave">确定</n-button> |
| | | </n-space> |
| | | </template> |
| | | </n-modal> |
| | | </template> |
| | | |
| | | <style scoped></style> |
| | | <style scoped lang="scss"> |
| | | ::v-deep .n-upload-trigger--image-card { |
| | | width: 56px !important; |
| | | height: 56px; |
| | | } |
| | | |
| | | ::v-deep .n-upload-file-list .n-upload-file.n-upload-file--image-card-type { |
| | | width: 56px !important; |
| | | height: 56px; |
| | | } |
| | | |
| | | ::v-deep .n-form-item-blank { |
| | | div:nth-child(1) { |
| | | width: 100%; |
| | | } |
| | | } |
| | | |
| | | ::v-deep .n-upload-file-list.n-upload-file-list--grid { |
| | | grid-template-columns: repeat(auto-fill, 56px); |
| | | } |
| | | |
| | | ::v-deep .n-upload-dragger { |
| | | background-color: transparent; |
| | | border: 1px solid rgba(23, 31, 38, 0.15); |
| | | } |
| | | </style> |
| | |
| | | <script setup lang="tsx"> |
| | | import { ref, h, onMounted } from "vue"; |
| | | import type { DataTableColumns } from "naive-ui"; |
| | | import { NButton } from "naive-ui"; |
| | | import { NButton, NTag } from "naive-ui"; |
| | | import { useRouter } from "vue-router"; |
| | | import { useBoolean } from "@/hooks"; |
| | | import { |
| | |
| | | } from "@/service/api/serviceCenter"; |
| | | import WorkOrderModal from "./components/WorkOrderModal.vue"; |
| | | import WorkOrderHandleModal from "./components/WorkOrderHandleModal.vue"; |
| | | import dayjs from "dayjs"; |
| | | |
| | | interface WorkOrderItem { |
| | | id: number; |
| | |
| | | title: "序号", |
| | | key: "index", |
| | | width: 80, |
| | | align: "center", |
| | | resizable: true, |
| | | render: (_row, index) => |
| | | h( |
| | | "span", |
| | |
| | | { |
| | | title: "提交用户", |
| | | key: "feedbackUserPhone", |
| | | width: 140, |
| | | align: "center", |
| | | width: 100, |
| | | resizable: true, |
| | | render: (row) => row.feedbackUserPhone || "-", |
| | | }, |
| | | { |
| | | title: "来源", |
| | | key: "problemSource", |
| | | width: 120, |
| | | align: "center", |
| | | resizable: true, |
| | | render: (row) => problemSourceLabelMap[row.problemSource || 0] || "-", |
| | | }, |
| | | { |
| | | title: "类型", |
| | | key: "feedbackTypeName", |
| | | width: 140, |
| | | align: "center", |
| | | width: 100, |
| | | resizable: true, |
| | | render: (row) => row.feedbackTypeName || "-", |
| | | }, |
| | | { |
| | | title: "问题", |
| | | key: "problem", |
| | | minWidth: 200, |
| | | align: "center", |
| | | width: 100, |
| | | resizable: true, |
| | | render: (row) => row.problem || "-", |
| | | }, |
| | | { |
| | | title: "优先级", |
| | | key: "priority", |
| | | width: 120, |
| | | align: "center", |
| | | width: 80, |
| | | resizable: true, |
| | | render: (row) => { |
| | | const priority = row.priority || 0; |
| | | const color = priority === 3 ? "red" : priority === 2 ? "orange" : "green"; |
| | | return h("span", { style: { color } }, priorityLabelMap[priority] || "-"); |
| | | const tagType = priority === 3 ? "error" : priority === 2 ? "warning" : "success"; |
| | | const statusText = priorityLabelMap[priority] || "-"; |
| | | return ( |
| | | <NTag type={tagType as any} bordered={false} size="small"> |
| | | {statusText} |
| | | </NTag> |
| | | ); |
| | | }, |
| | | }, |
| | | { |
| | | title: "处理状态", |
| | | key: "handleStatus", |
| | | width: 120, |
| | | align: "center", |
| | | render: (row) => statusLabelMap[row.handleStatus || 0] || "-", |
| | | width: 80, |
| | | resizable: true, |
| | | render: (row) => { |
| | | const handleStatus = row.handleStatus || 0; |
| | | const tagType = handleStatus === 3 ? "default" : handleStatus === 2 ? "warning" : "error"; |
| | | const statusText = statusLabelMap[handleStatus] || "-"; |
| | | return ( |
| | | <NTag type={tagType as any} bordered={false} size="small"> |
| | | {statusText} |
| | | </NTag> |
| | | ); |
| | | }, |
| | | }, |
| | | { |
| | | title: "处理人", |
| | | key: "handleUserName", |
| | | width: 120, |
| | | align: "center", |
| | | width: 80, |
| | | resizable: true, |
| | | render: (row) => row.handleUserName || "-", |
| | | }, |
| | | { |
| | | title: "添加时间", |
| | | key: "createTime", |
| | | width: 180, |
| | | align: "center", |
| | | render: (row) => row.createTime || "-", |
| | | width: 150, |
| | | resizable: true, |
| | | render: (row) => dayjs(row.createTime).format("YYYY/MM/DD HH:mm:ss") || "-", |
| | | }, |
| | | { |
| | | title: "操作", |
| | | key: "actions", |
| | | width: 260, |
| | | align: "center", |
| | | width: 140, |
| | | resizable: true, |
| | | render: (row) => { |
| | | return ( |
| | | <div class="flex items-center justify-center gap-2"> |
| | | <div class="flex items-center gap-2"> |
| | | <NButton |
| | | v-permission="80212" |
| | | text |
| | |
| | | 处理 |
| | | </NButton> |
| | | ) : null} |
| | | <NButton |
| | | {/* <NButton |
| | | v-permission="80215" |
| | | text |
| | | type="primary" |
| | | onClick={() => handleEdit(row)} |
| | | > |
| | | 编辑 |
| | | </NButton> |
| | | </NButton> */} |
| | | <NButton |
| | | v-permission="80216" |
| | | text |
| | | type="error" |
| | | type="primary" |
| | | onClick={() => handleDelete(row)} |
| | | > |
| | | 删除 |
| | |
| | | }; |
| | | if (payload.handleType === 2) { |
| | | window.$dialog?.warning({ |
| | | title: "确认结办", |
| | | content: "是否确认结办?", |
| | | title: "是否确认结办?", |
| | | content: "", |
| | | positiveText: "确定", |
| | | negativeText: "取消", |
| | | onPositiveClick: execSubmit, |
| | |
| | | getHandleUserOptions(); |
| | | getWorkOrderList(); |
| | | }); |
| | | |
| | | |
| | | const isProblemSourceSelectOpen = ref(false); |
| | | const isFeedbackTypeSelectOpen = ref(false); |
| | | const isPrioritySelectOpen = ref(false); |
| | | const isHandleStatusSelectOpen = ref(false); |
| | | |
| | | const filterItems = computed(() => [ |
| | | { |
| | | label: '用户', |
| | | path: 'feedbackUserPhone', |
| | | render: () => <n-input v-model:value={searchForm.value.feedbackUserPhone} placeholder='请输入' />, |
| | | }, |
| | | { |
| | | label: '问题来源', |
| | | path: 'problemSource', |
| | | render: () => ( |
| | | <n-select |
| | | v-model:value={searchForm.value.problemSource} |
| | | options={problemSourceOptions} |
| | | placeholder='请选择' |
| | | clearable |
| | | onUpdate:show={val => isProblemSourceSelectOpen.value = val} |
| | | > |
| | | {{ |
| | | arrow: () => ( |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | class={`arrow-icon ${isProblemSourceSelectOpen.value ? 'arrow-icon--open' : ''}`} |
| | | style={{ width: '16px', height: '16px' }} |
| | | alt="arrow" |
| | | /> |
| | | ), |
| | | }} |
| | | </n-select> |
| | | ), |
| | | }, |
| | | { |
| | | label: '类型', |
| | | path: 'feedbackTypeId', |
| | | render: () => ( |
| | | <n-select |
| | | v-model:value={searchForm.value.feedbackTypeId} |
| | | options={typeOptions.value} |
| | | placeholder='请选择' |
| | | clearable |
| | | onUpdate:show={val => isFeedbackTypeSelectOpen.value = val} |
| | | > |
| | | {{ |
| | | arrow: () => ( |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | class={`arrow-icon ${isFeedbackTypeSelectOpen.value ? 'arrow-icon--open' : ''}`} |
| | | style={{ width: '16px', height: '16px' }} |
| | | alt="arrow" |
| | | /> |
| | | ), |
| | | }} |
| | | </n-select> |
| | | ), |
| | | }, |
| | | { |
| | | label: '优先级', |
| | | path: 'priority', |
| | | render: () => ( |
| | | <n-select |
| | | v-model:value={searchForm.value.priority} |
| | | options={priorityOptions} |
| | | placeholder='请选择' |
| | | clearable |
| | | onUpdate:show={val => isPrioritySelectOpen.value = val} |
| | | > |
| | | {{ |
| | | arrow: () => ( |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | class={`arrow-icon ${isPrioritySelectOpen.value ? 'arrow-icon--open' : ''}`} |
| | | style={{ width: '16px', height: '16px' }} |
| | | alt="arrow" |
| | | /> |
| | | ), |
| | | }} |
| | | </n-select> |
| | | ), |
| | | }, |
| | | { |
| | | label: '状态', |
| | | path: 'handleStatus', |
| | | render: () => ( |
| | | <n-select |
| | | v-model:value={searchForm.value.handleStatus} |
| | | options={statusOptions} |
| | | placeholder='请选择' |
| | | clearable |
| | | onUpdate:show={val => isHandleStatusSelectOpen.value = val} |
| | | > |
| | | {{ |
| | | arrow: () => ( |
| | | <img |
| | | src="/src/assets/element-img/select-down.png" |
| | | class={`arrow-icon ${isHandleStatusSelectOpen.value ? 'arrow-icon--open' : ''}`} |
| | | style={{ width: '16px', height: '16px' }} |
| | | alt="arrow" |
| | | /> |
| | | ), |
| | | }} |
| | | </n-select> |
| | | ), |
| | | }, |
| | | ]) |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="h-full flex flex-col"> |
| | | <n-card style="border-radius: 6px" content-style="padding: 12px"> |
| | | <div class="roboTaxi"> |
| | | <div ref="formRef" class="roboTaxi-form-card mb-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" |
| | | /> |
| | | <SearchPanel> |
| | | <n-gi v-for="item in filterItems" :key="item.path"> |
| | | <n-form-item :label="item.label" :path="item.path"> |
| | | <component :is="item.render" /> |
| | | </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-gi> |
| | | <template #action> |
| | | <NButton strong @click="handleReset"> |
| | | {{ $t("common.reset") }} |
| | | </NButton> |
| | | <NButton type="primary" @click="handleSearch"> |
| | | {{ $t("common.search") }} |
| | | </NButton> |
| | | </template> |
| | | </SearchPanel> |
| | | </n-form> |
| | | </n-card> |
| | | </div> |
| | | |
| | | <n-card |
| | | style="border-radius: 6px; margin-top: 12px" |
| | | content-style="padding: 12px" |
| | | class="flex-1 robotaxi-table-card" |
| | | content-style="display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0;" |
| | | > |
| | | <div class="mb-4 flex justify-end gap-2"> |
| | | <n-button |
| | | v-permission="80211" |
| | | style=" |
| | | width: 96px; |
| | | height: 32px; |
| | | color: #0068ff; |
| | | background-color: #e6f4ff; |
| | | border: 1px solid #7abdff; |
| | | " |
| | | @click="handleCreate" |
| | | >创建工单</n-button |
| | | > |
| | | </div> |
| | | <template #header> |
| | | <n-flex v-permission="80211" justify="flex-end" |
| | | ><n-button type="primary" secondary @click="handleCreate" |
| | | >创建工单</n-button |
| | | > |
| | | </n-flex> |
| | | </template> |
| | | |
| | | <NSpace vertical> |
| | | <div |
| | | class="flex-1 flex flex-col h-full" |
| | | style="min-width: 0; overflow: hidden" |
| | | > |
| | | <n-data-table |
| | | class="flex-1" |
| | | flex-height |
| | | :columns="columns" |
| | | :data="tableData" |
| | | :loading="loading" |
| | |
| | | :count="count" |
| | | :page="pagination.page" |
| | | :page-size="pagination.pageSize" |
| | | class="robotaxi-pagination mt-12px" |
| | | @change="changePage" |
| | | /> |
| | | </NSpace> |
| | | </div> |
| | | </n-card> |
| | | |
| | | <WorkOrderModal |