| | |
| | | <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]; |
| | |
| | | result: "pass", |
| | | auditRemarks: "", |
| | | }); |
| | | const detailLoading = ref(false); |
| | | const reviewDetail = ref<QualificationDetail | null>(null); |
| | | |
| | | let currentRequestId = 0; |
| | | |
| | | const rules: FormRules = { |
| | | result: [ |
| | |
| | | ], |
| | | }; |
| | | |
| | | 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> |
| | |
| | | :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"> |
| | |
| | | </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> |