hejianhao
2026-03-19 988f35b8ae2c9389153701bc2103a2cc3ed58539
feat(工单管理): 优化工单处理界面及上传组件功能

- 添加工单处理模态框的拖拽上传功能,支持图片、音频、视频等多种文件格式
- 为所有下拉选择器添加自定义箭头图标,提升视觉一致性
- 优化工单列表的展示样式,使用标签显示优先级和处理状态
- 修复文件上传组件中ID赋值问题,避免破坏Naive UI内部追踪逻辑
- 新增多个公共图标资源,用于文件类型展示和操作按钮
- 统一工单创建和编辑界面的布局和文案,改善用户体验
9个文件已添加
7个文件已修改
868 ■■■■■ 已修改文件
src/assets/public/delete-file-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/download-file-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/error-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/img-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/info-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/ppt-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/reload-upload-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/upload-folder-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/word-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/upload.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/review/personnel/components/ReviewModal.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/review/personnel/index.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/review/policyApplication/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/components/WorkOrderHandleModal.vue 351 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/components/WorkOrderModal.vue 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/index.vue 296 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/public/delete-file-icon.png
src/assets/public/download-file-icon.png
src/assets/public/error-icon.png
src/assets/public/img-icon.png
src/assets/public/info-icon.png
src/assets/public/ppt-icon.png
src/assets/public/reload-upload-icon.png
src/assets/public/upload-folder-icon.png
src/assets/public/word-icon.png
src/utils/upload.ts
@@ -57,12 +57,12 @@
    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',
      }
src/views/service/review/personnel/components/ReviewModal.vue
@@ -31,7 +31,11 @@
const rules: FormRules = {
  result: [
    { required: true, message: t("serviceReview.common.chooseResult"), trigger: "change" },
    {
      required: true,
      message: t("serviceReview.common.chooseResult"),
      trigger: "change",
    },
  ],
  auditRemarks: [
    {
@@ -93,10 +97,16 @@
      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>
@@ -128,4 +138,5 @@
  </n-modal>
</template>
<style scoped></style>
<style scoped>
</style>
src/views/service/review/personnel/index.vue
@@ -397,6 +397,9 @@
  getPersonnelList()
})
const isRoleSelectOpen = ref(false);
const isAuditStatusSelectOpen = ref(false);
const filterItems = computed(() => [
  {
    label: t('serviceReview.personnel.searchName'),
@@ -411,12 +414,50 @@
  {
    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>
src/views/service/review/policyApplication/index.vue
@@ -310,6 +310,8 @@
  getPolicyApplicationList();
});
const isAuditStatusSelectOpen = ref(false);
const filterItems = computed(() => [
  {
    label: t("serviceReview.policyApplication.plateNumber"),
@@ -324,7 +326,26 @@
  {
    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"),
src/views/service/ticket/workOrder/components/WorkOrderHandleModal.vue
@@ -1,6 +1,6 @@
<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 {
@@ -38,12 +38,24 @@
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);
@@ -78,65 +90,119 @@
    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),
  });
}
@@ -156,24 +222,31 @@
    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">
@@ -181,11 +254,52 @@
                    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>
@@ -195,11 +309,14 @@
        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"
@@ -209,20 +326,108 @@
            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>
@@ -232,7 +437,20 @@
            :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>
@@ -251,4 +469,9 @@
  </n-modal>
</template>
<style scoped></style>
<style scoped lang="scss">
::v-deep .n-upload-dragger {
  padding: 40px 0;
  background-color: transparent;
}
</style>
src/views/service/ticket/workOrder/components/WorkOrderModal.vue
@@ -56,6 +56,11 @@
});
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: [
@@ -103,7 +108,7 @@
};
const modalTitle = computed(() =>
  props.mode === "add" ? "新增工单" : "编辑工单"
  props.mode === "add" ? "创建工单" : "编辑工单"
);
function resetForm() {
@@ -211,14 +216,15 @@
    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"
    >
@@ -235,7 +241,9 @@
          type="textarea"
          placeholder="请输入"
          :rows="4"
          maxlength="200"
          clearable
          show-count
        />
      </n-form-item>
      <n-form-item label="图片" path="problemImage">
@@ -243,28 +251,37 @@
          <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>
@@ -281,7 +298,20 @@
          :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>
@@ -290,7 +320,20 @@
            :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
@@ -298,7 +341,20 @@
            :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>
@@ -307,17 +363,55 @@
          :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>
src/views/service/ticket/workOrder/index.vue
@@ -1,7 +1,7 @@
<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 {
@@ -15,6 +15,7 @@
} from "@/service/api/serviceCenter";
import WorkOrderModal from "./components/WorkOrderModal.vue";
import WorkOrderHandleModal from "./components/WorkOrderHandleModal.vue";
import dayjs from "dayjs";
interface WorkOrderItem {
  id: number;
@@ -99,7 +100,7 @@
    title: "序号",
    key: "index",
    width: 80,
    align: "center",
    resizable: true,
    render: (_row, index) =>
      h(
        "span",
@@ -109,71 +110,85 @@
  {
    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
@@ -192,18 +207,18 @@
              处理
            </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)}
          >
            删除
@@ -330,8 +345,8 @@
  };
  if (payload.handleType === 2) {
    window.$dialog?.warning({
      title: "确认结办",
      content: "是否确认结办?",
      title: "是否确认结办?",
      content: "",
      positiveText: "确定",
      negativeText: "取消",
      onPositiveClick: execSubmit,
@@ -416,100 +431,164 @@
  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"
@@ -520,9 +599,10 @@
          :count="count"
          :page="pagination.page"
          :page-size="pagination.pageSize"
          class="robotaxi-pagination mt-12px"
          @change="changePage"
        />
      </NSpace>
      </div>
    </n-card>
    <WorkOrderModal