董国庆
2026-05-25 1ea6cc52380907893e96bebbcde5c92ece8e0809
feat(service/review/personnel): 新增资质详情查看并优化人员审核流程

- 新增获取资质审核详情的API接口
- 移除表格中冗余的证件图片列,调整表格横向滚动宽度
- 重写审核弹窗,支持单条审核展示详情、批量审核显示提示
- 新增相关监听器,优化表单重置与详情加载逻辑
- 完善资质证件图片的解析与展示逻辑
3个文件已修改
633 ■■■■■ 已修改文件
src/service/api/serviceCenter.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/review/personnel/components/ReviewModal.vue 505 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/review/personnel/index.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/serviceCenter.ts
@@ -15,6 +15,10 @@
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/qualificationAudit/audit', data)
}
export function fetchQualificationAuditInfo(id: number | string) {
  return request.Get<Service.ResponseResult<any>>(`/account/api/v1/qualificationAudit/getQualificationAuditInfo/${id}`)
}
export function deleteQualificationAuditById(id: number | string) {
  return request.Delete<Service.ResponseResult<any>>('/account/api/v1/qualificationAudit/deleteById?id=' + id)
}
src/views/service/review/personnel/components/ReviewModal.vue
@@ -1,17 +1,40 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { computed, ref, watch } from "vue";
import type { FormInst, FormRules } from "naive-ui";
import { fetchQualificationAuditInfo } from "@/service/api/serviceCenter";
interface FormModel {
  result: "pass" | "reject";
  auditRemarks: string;
}
interface QualificationDetail {
  avatar?: string;
  certificateOfNoCriminalRecord?: string;
  driverLicense?: string;
  id?: number | string;
  idCardBack?: string;
  idCardFront?: string;
  nickName?: string;
  operationsUserId?: number | string;
  phoneNumber?: string;
  roleNames?: string[] | string;
  trainingGraduationCertificate?: string;
}
const { t } = useI18n();
const props = defineProps<{
  show: boolean;
}>();
const props = withDefaults(
  defineProps<{
    reviewId?: number | null;
    selectedCount?: number;
    show: boolean;
  }>(),
  {
    reviewId: null,
    selectedCount: 0,
  }
);
const emit = defineEmits<{
  "update:show": [value: boolean];
@@ -28,6 +51,10 @@
  result: "pass",
  auditRemarks: "",
});
const detailLoading = ref(false);
const reviewDetail = ref<QualificationDetail | null>(null);
let currentRequestId = 0;
const rules: FormRules = {
  result: [
@@ -53,28 +80,141 @@
  ],
};
function resetForm() {
  formModel.value = {
    result: "pass",
    auditRemarks: "",
  };
  formRef.value?.restoreValidation();
}
function resetDetail() {
  currentRequestId += 1;
  detailLoading.value = false;
  reviewDetail.value = null;
}
function getRoleNames(value: QualificationDetail["roleNames"]) {
  if (Array.isArray(value)) {
    return value;
  }
  if (typeof value === "string" && value.trim()) {
    return value.split(",").map(item => item.trim()).filter(Boolean);
  }
  return [];
}
function parseImageList(value: unknown) {
  if (!value) {
    return [] as string[];
  }
  if (Array.isArray(value)) {
    return value
      .map(item => {
        if (typeof item === "string") return item;
        return item?.url || "";
      })
      .filter(Boolean);
  }
  if (typeof value === "object") {
    return [(value as { url?: string }).url || ""].filter(Boolean);
  }
  try {
    const parsed = JSON.parse(String(value));
    if (Array.isArray(parsed)) {
      return parsed
        .map(item => {
          if (typeof item === "string") return item;
          return item?.url || "";
        })
        .filter(Boolean);
    }
    if (parsed?.url) {
      return [parsed.url];
    }
  } catch {
    return [];
  }
  return [];
}
function getIdCardImages(detail: QualificationDetail | null) {
  if (!detail) {
    return [];
  }
  return [
    ...parseImageList(detail.idCardFront),
    ...parseImageList(detail.idCardBack),
  ];
}
async function loadDetail(id: number | string) {
  const requestId = ++currentRequestId;
  detailLoading.value = true;
  try {
    const res = await fetchQualificationAuditInfo(id);
    if (requestId !== currentRequestId) {
      return;
    }
    if (res.code === 200 && res.data) {
      reviewDetail.value = res.data;
      return;
    }
    reviewDetail.value = null;
    window.$message?.error(res.msg || "获取审核详情失败");
  } catch (error) {
    if (requestId !== currentRequestId) {
      return;
    }
    reviewDetail.value = null;
    console.error("获取审核详情失败", error);
    window.$message?.error("获取审核详情失败");
  } finally {
    if (requestId === currentRequestId) {
      detailLoading.value = false;
    }
  }
}
const handleCancel = () => {
  show.value = false;
  resetForm();
};
const handleConfirm = async () => {
  try {
    await formRef.value?.validate();
    emit("confirm", formModel.value);
    emit("confirm", { ...formModel.value });
    show.value = false;
    resetForm();
  } catch (error) {
    console.log(t("serviceReview.common.validateFail"), error);
  }
};
const resetForm = () => {
  formModel.value = {
    result: "pass",
    auditRemarks: "",
  };
};
watch(
  () => [props.show, props.reviewId] as const,
  ([visible, reviewId]) => {
    if (!visible) {
      resetForm();
      resetDetail();
      return;
    }
    if (reviewId) {
      loadDetail(reviewId);
      return;
    }
    resetDetail();
  },
  { immediate: true }
);
</script>
<template>
@@ -83,49 +223,211 @@
    :mask-closable="false"
    preset="card"
    :title="t('serviceReview.common.review')"
    class="w-400px"
    :style="{ width: '1000px' }"
    :auto-focus="false"
    :segmented="{
      content: true,
      action: true,
    }"
    :content-style="{ padding: '24px', maxHeight: '80vh', overflow: 'auto' }"
  >
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="left"
      require-mark-placement="left"
    >
      <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-group>
      </n-form-item>
    <n-spin :show="detailLoading">
      <div class="review-modal-content">
        <div v-if="reviewDetail" class="section-card">
          <div class="section-title">人员资质审核</div>
      <n-form-item
        :label="t('serviceReview.common.remark')"
        path="auditRemarks"
        label-placement="top"
        :required="formModel.result === 'reject'"
      >
        <n-input
          v-model:value="formModel.auditRemarks"
          type="textarea"
          :placeholder="t('common.inputPlaceholder')"
          :rows="4"
          show-count
          maxlength="200"
        />
      </n-form-item>
    </n-form>
          <div class="base-info-grid">
            <div class="info-item">
              <div class="info-label">姓名:</div>
              <div class="info-value name-value">
                <img
                  v-if="parseImageList(reviewDetail.avatar)[0]"
                  :src="parseImageList(reviewDetail.avatar)[0]"
                  class="avatar"
                  alt="avatar"
                />
                <span>{{ reviewDetail.nickName || "-" }}</span>
              </div>
            </div>
            <div class="info-item">
              <div class="info-label">编号:</div>
              <div class="info-value">
                {{ reviewDetail.operationsUserId || "-" }}
              </div>
            </div>
            <div class="info-item">
              <div class="info-label">手机号:</div>
              <div class="info-value">
                {{ reviewDetail.phoneNumber || "-" }}
              </div>
            </div>
          </div>
          <div class="info-row">
            <div class="info-label">角色:</div>
            <div class="info-value role-list">
              <n-tag
                v-for="role in getRoleNames(reviewDetail.roleNames)"
                :key="role"
                type="info"
                :bordered="false"
                size="small"
              >
                {{ role }}
              </n-tag>
              <span v-if="getRoleNames(reviewDetail.roleNames).length === 0">-</span>
            </div>
          </div>
          <div class="certificate-grid">
            <div class="info-row">
              <div class="info-label">身份证:</div>
              <div class="info-value">
                <div v-if="getIdCardImages(reviewDetail).length" class="image-list">
                  <n-image
                    v-for="(url, index) in getIdCardImages(reviewDetail)"
                    :key="`${url}-${index}`"
                    :src="url"
                    :width="88"
                    :height="58"
                    object-fit="cover"
                    fallback-src="/src/assets/public/placeholder.png"
                  />
                </div>
                <span v-else>-</span>
              </div>
            </div>
            <div class="info-row">
              <div class="info-label">驾驶证:</div>
              <div class="info-value">
                <div
                  v-if="parseImageList(reviewDetail.driverLicense).length"
                  class="image-list"
                >
                  <n-image
                    v-for="(url, index) in parseImageList(reviewDetail.driverLicense)"
                    :key="`${url}-${index}`"
                    :src="url"
                    :width="88"
                    :height="58"
                    object-fit="cover"
                    fallback-src="/src/assets/public/placeholder.png"
                  />
                </div>
                <span v-else>-</span>
              </div>
            </div>
            <div class="info-row">
              <div class="info-label">无犯罪记录证明:</div>
              <div class="info-value">
                <div
                  v-if="
                    parseImageList(reviewDetail.certificateOfNoCriminalRecord).length
                  "
                  class="image-list"
                >
                  <n-image
                    v-for="
                      (url, index) in parseImageList(
                        reviewDetail.certificateOfNoCriminalRecord
                      )
                    "
                    :key="`${url}-${index}`"
                    :src="url"
                    :width="88"
                    :height="58"
                    object-fit="cover"
                    fallback-src="/src/assets/public/placeholder.png"
                  />
                </div>
                <span v-else>-</span>
              </div>
            </div>
            <div class="info-row">
              <div class="info-label">培训结业证:</div>
              <div class="info-value">
                <div
                  v-if="
                    parseImageList(reviewDetail.trainingGraduationCertificate).length
                  "
                  class="image-list"
                >
                  <n-image
                    v-for="
                      (url, index) in parseImageList(
                        reviewDetail.trainingGraduationCertificate
                      )
                    "
                    :key="`${url}-${index}`"
                    :src="url"
                    :width="88"
                    :height="58"
                    object-fit="cover"
                    fallback-src="/src/assets/public/placeholder.png"
                  />
                </div>
                <span v-else>-</span>
              </div>
            </div>
          </div>
        </div>
        <div v-else-if="!detailLoading" class="section-card batch-tip">
          已选择 {{ selectedCount }} 条待审核记录
        </div>
        <div class="section-card">
          <div class="section-title">审核信息</div>
          <n-form
            ref="formRef"
            :model="formModel"
            :rules="rules"
            label-placement="left"
            label-width="72"
            require-mark-placement="left"
          >
            <n-form-item
              :label="t('serviceReview.common.auditResult')"
              path="result"
              required
            >
              <n-radio-group v-model:value="formModel.result">
                <n-space>
                  <n-radio value="pass">
                    {{ t("serviceReview.common.pass") }}
                  </n-radio>
                  <n-radio value="reject">
                    {{ t("serviceReview.common.reject") }}
                  </n-radio>
                </n-space>
              </n-radio-group>
            </n-form-item>
            <n-form-item
              :label="t('serviceReview.common.remark')"
              path="auditRemarks"
              label-placement="top"
              :required="formModel.result === 'reject'"
            >
              <n-input
                v-model:value="formModel.auditRemarks"
                type="textarea"
                :placeholder="t('common.inputPlaceholder')"
                :rows="4"
                show-count
                maxlength="200"
              />
            </n-form-item>
          </n-form>
        </div>
      </div>
    </n-spin>
    <template #action>
      <n-space justify="end">
@@ -139,4 +441,109 @@
</template>
<style scoped>
.review-modal-content {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.section-card {
  padding: 20px 24px;
  border: 1px solid rgba(23, 31, 38, 0.08);
  border-radius: 8px;
  background: #fff;
}
.section-title {
  position: relative;
  margin-bottom: 20px;
  padding-left: 11px;
  color: #171f26;
  font-size: 16px;
  font-weight: 600;
  line-height: 24px;
}
.section-title::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  width: 3px;
  height: 14px;
  border-radius: 3px;
  background: #0068ff;
  transform: translateY(-50%);
}
.base-info-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 16px 24px;
  margin-bottom: 16px;
}
.certificate-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px 40px;
}
.info-item,
.info-row {
  display: flex;
  align-items: flex-start;
  min-width: 0;
}
.info-label {
  flex-shrink: 0;
  color: #171f26;
  font-size: 14px;
  line-height: 22px;
  font-weight: 600;
}
.info-value {
  min-width: 0;
  color: #171f26;
  font-size: 14px;
  line-height: 22px;
  word-break: break-all;
}
.name-value {
  display: flex;
  align-items: center;
  gap: 8px;
}
.role-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.image-list {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  object-fit: cover;
}
.batch-tip {
  color: rgba(23, 31, 38, 0.8);
  font-size: 14px;
  line-height: 22px;
}
:deep(.n-image img) {
  border-radius: 4px;
}
</style>
src/views/service/review/personnel/index.vue
@@ -1,7 +1,7 @@
<script setup lang="tsx">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NImage, NSpace, NTag, NTooltip } from 'naive-ui'
import { NButton, NSpace, NTag, NTooltip } from 'naive-ui'
import { useBoolean } from '@/hooks'
import OperationLogModal from '@/components/custom/OperationLogModal.vue'
import {
@@ -116,104 +116,6 @@
    },
  },
  {
    title: t('serviceReview.personnel.idCard'),
    key: 'idCard',
    width: 160,
    resizable: true,
    render: (row) => {
      if (row.idCardFront && row.idCardBack) {
        const idCardFront = JSON.parse(row.idCardFront).url
        const idCardBack = JSON.parse(row.idCardBack).url
        return (
          <div class="flex gap-1">
            <NImage
              width={60}
              height={40}
              src={idCardFront || '/src/assets/public/placeholder.png'}
              previewSrc={idCardFront}
              fallbackSrc="/src/assets/public/placeholder.png"
            />
            <NImage
              width={60}
              height={40}
              src={idCardBack || '/src/assets/public/placeholder.png'}
              previewSrc={idCardBack}
              fallbackSrc="/src/assets/public/placeholder.png"
            />
          </div>
        )
      }
      return '-'
    },
  },
  {
    title: t('serviceReview.personnel.driverLicense'),
    key: 'driverLicense',
    width: 100,
    resizable: true,
    render: (row) => {
      if (row.driverLicense) {
        const driverLicense = JSON.parse(row.driverLicense).url
        return (
          <NImage
            width={60}
            height={40}
            src={driverLicense || '/src/assets/public/placeholder.png'}
            previewSrc={driverLicense}
            fallbackSrc="/src/assets/public/placeholder.png"
          />
        )
      }
      return '-'
    },
  },
  {
    title: t('serviceReview.personnel.noCriminalRecord'),
    key: 'certificateOfNoCriminalRecord',
    width: 140,
    resizable: true,
    render: (row) => {
      if (row.certificateOfNoCriminalRecord) {
        const certificateOfNoCriminalRecord = JSON.parse(
          row.certificateOfNoCriminalRecord,
        ).url
        return (
          <NImage
            width={60}
            height={40}
            src={certificateOfNoCriminalRecord || '/src/assets/public/placeholder.png'}
            previewSrc={certificateOfNoCriminalRecord}
            fallbackSrc="/src/assets/public/placeholder.png"
          />
        )
      }
      return '-'
    },
  },
  {
    title: t('serviceReview.personnel.trainingGraduationCertificate'),
    key: 'trainingGraduationCertificate',
    width: 140,
    resizable: true,
    render: (row) => {
      if (row.trainingGraduationCertificate) {
        const trainingGraduationCertificate = JSON.parse(
          row.trainingGraduationCertificate,
        ).url
        return (
          <NImage
            width={60}
            height={40}
            src={trainingGraduationCertificate || '/src/assets/public/placeholder.png'}
            previewSrc={trainingGraduationCertificate}
            fallbackSrc="/src/assets/public/placeholder.png"
          />
        )
      }
      return '-'
    },
  },
  {
    title: t('serviceReview.personnel.status'),
    key: 'auditStatus',
    width: 100,
@@ -278,6 +180,8 @@
const count = ref(0)
const showReviewModal = ref(false)
const currentReviewId = ref<number | null>(null)
const reviewIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
const showOperationLogModal = ref(false)
@@ -297,6 +201,8 @@
    window.$message?.warning(t('serviceReview.common.selectPersonnelToReview'))
    return
  }
  currentReviewId.value = null
  reviewIds.value = [...selectedIds.value]
  showReviewModal.value = true
}
@@ -305,7 +211,8 @@
}
function handleApprove(id: number) {
  selectedIds.value = [id]
  currentReviewId.value = id
  reviewIds.value = [id]
  showReviewModal.value = true
}
@@ -333,11 +240,13 @@
  const auditStatus = data?.result === 'pass' ? 2 : 3
  const res = await auditQualification({
    auditStatus,
    ids: selectedIds.value,
    ids: reviewIds.value,
    auditRemarks: data?.auditRemarks || '',
  })
  if (res.code === 200) {
    window.$message?.success(t('serviceReview.common.reviewSuccess'))
    reviewIds.value = []
    currentReviewId.value = null
    selectedIds.value = []
    getPersonnelList()
  }
@@ -355,6 +264,13 @@
  pagination.value.pageSize = pageSize
  getPersonnelList()
}
watch(showReviewModal, (visible) => {
  if (!visible) {
    currentReviewId.value = null
    reviewIds.value = []
  }
})
async function getPersonnelList() {
  startLoading()
@@ -548,7 +464,7 @@
          :columns="columns"
          :data="tableData"
          :loading="loading"
          :scroll-x="1470"
          :scroll-x="1030"
          :bordered="false"
          :row-key="(row: any) => row.id"
          @update:checked-row-keys="handleCheck"
@@ -571,6 +487,8 @@
    <ReviewModal
      v-model:show="showReviewModal"
      :review-id="currentReviewId"
      :selected-count="reviewIds.length"
      @confirm="handleConfirmReview"
    />
    <OperationLogModal v-model:show="showOperationLogModal" />