Files
tydata-webview-v2/src/ui/IVYZ3P9M.vue

697 lines
24 KiB
Vue
Raw Normal View History

2025-11-13 22:27:54 +08:00
<script setup>
import { computed } from 'vue';
import { useRiskNotifier } from '@/composables/useRiskNotifier';
import xlIcon from '@/assets/images/report/xl.png';
import zymcIcon from '@/assets/images/report/zymc.png';
import xxxsIcon from '@/assets/images/report/xxxs.png';
import xxlxIcon from '@/assets/images/report/xxlx.png';
import bysjIcon from '@/assets/images/report/bysj.png';
import dictionaries from '@/data/ivyz3p9m-dictionary.json';
const props = defineProps({
data: {
type: [Array, Object],
default: () => [],
},
apiId: {
type: String,
default: '',
},
index: {
type: Number,
default: 0,
},
notifyRiskStatus: {
type: Function,
default: () => { },
},
});
const riskScore = computed(() => 100);
useRiskNotifier(props, riskScore);
defineExpose({ riskScore });
const educationLevelMap = dictionaries.educationLevel ?? {};
const learningFormMap = dictionaries.learningForm ?? {};
const schoolDictionary = dictionaries.schools ?? {};
const specialtyDictionary = dictionaries.specialties ?? {};
const educationTagColorMap = {
'1': 'bg-emerald-50 text-emerald-700 border-emerald-200',
'2': 'bg-blue-50 text-blue-700 border-blue-200',
'3': 'bg-indigo-50 text-indigo-700 border-indigo-200',
'4': 'bg-purple-50 text-purple-700 border-purple-200',
'5': 'bg-amber-50 text-amber-700 border-amber-200',
};
const learningFormColorMap = {
'1': 'bg-sky-50 text-sky-700 border-sky-200',
'2': 'bg-blue-50 text-blue-700 border-blue-200',
'3': 'bg-blue-50 text-blue-700 border-blue-200',
'4': 'bg-cyan-50 text-cyan-700 border-cyan-200',
'5': 'bg-orange-50 text-orange-700 border-orange-200',
'6': 'bg-lime-50 text-lime-700 border-lime-200',
'7': 'bg-indigo-50 text-indigo-700 border-indigo-200',
'8': 'bg-violet-50 text-violet-700 border-violet-200',
'9': 'bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200',
};
const normalizeCode = (value) => {
if (value === null || value === undefined) return '';
return String(value).trim();
};
const getEducationLevelText = (code) => {
const normalized = normalizeCode(code);
if (!normalized) return '未知';
return educationLevelMap[normalized] || '未知';
};
const getLearningFormText = (code) => {
const normalized = normalizeCode(code);
if (!normalized) return '未知';
return learningFormMap[normalized] || '未知';
};
const getSchoolNameText = (code, fallback) => {
const normalized = normalizeCode(code);
if (!normalized) return fallback || '未知学校';
return schoolDictionary[normalized] || fallback || '未知学校';
};
const getSpecialtyNameText = (code, fallback) => {
const normalized = normalizeCode(code);
if (!normalized) return fallback || '未知专业';
return specialtyDictionary[normalized] || fallback || '未知专业';
};
const getEducationLevelClass = (code) => {
const normalized = normalizeCode(code);
return educationTagColorMap[normalized] || 'bg-gray-50 text-gray-700 border-gray-200';
};
const getLearningFormClass = (code) => {
const normalized = normalizeCode(code);
return learningFormColorMap[normalized] || 'bg-gray-50 text-gray-700 border-gray-200';
};
const maskIdNumber = (idNumber) => {
if (!idNumber) return '未知';
const normalized = String(idNumber).trim();
if (normalized.length <= 6) {
return `${normalized.slice(0, 1)}****${normalized.slice(-1)}`;
}
return `${normalized.slice(0, 3)}********${normalized.slice(-4)}`;
};
const formatDate = (dateStr) => {
if (!dateStr) return '未知';
const normalized = String(dateStr).trim();
if (!/^\d+$/.test(normalized)) return '未知';
if (normalized.length === 8) {
const year = normalized.substring(0, 4);
const month = normalized.substring(4, 6);
const day = normalized.substring(6, 8);
return `${year}${month}${day}`;
}
if (normalized.length === 6) {
const year = normalized.substring(0, 4);
const month = normalized.substring(4, 6);
return `${year}${month}`;
}
if (normalized.length === 4) {
const shortYear = normalized.substring(0, 2);
const month = normalized.substring(2, 4);
return `20${shortYear}${month}`;
}
return '未知';
};
const parseDate = (value) => {
if (!value) return null;
const normalized = String(value).trim();
if (!/^\d+$/.test(normalized)) return null;
let year;
let month;
let day = 1;
if (normalized.length === 8) {
year = Number(normalized.substring(0, 4));
month = Number(normalized.substring(4, 6));
day = Number(normalized.substring(6, 8));
} else if (normalized.length === 6) {
year = Number(normalized.substring(0, 4));
month = Number(normalized.substring(4, 6));
} else if (normalized.length === 4) {
year = Number(`20${normalized.substring(0, 2)}`);
month = Number(normalized.substring(2, 4));
} else {
return null;
}
if (!year || !month) return null;
return new Date(year, month - 1, day);
};
const calculateStudyDuration = (start, end) => {
const startDate = parseDate(start);
const endDate = parseDate(end);
if (!startDate || !endDate || endDate < startDate) {
return '时长未知';
}
const totalMonths =
(endDate.getFullYear() - startDate.getFullYear()) * 12 +
(endDate.getMonth() - startDate.getMonth());
if (totalMonths <= 0) {
return '不足 1 个月';
}
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
const parts = [];
if (years > 0) parts.push(`${years}`);
if (months > 0) parts.push(`${months}个月`);
return parts.length > 0 ? `${parts.join('')}` : '约 1 个月';
};
const educationRecords = computed(() => {
const source = props.data;
if (Array.isArray(source)) return source;
if (Array.isArray(source?.data)) return source.data;
if (Array.isArray(source?.records)) return source.records;
if (Array.isArray(source?.list)) return source.list;
return [];
});
const enhancedRecords = computed(() =>
educationRecords.value.map((record, index) => {
const educationLevelCode = normalizeCode(record.educationLevel);
const learningFormCode = normalizeCode(record.learningForm);
const schoolCode = normalizeCode(record.schoolName);
const specialtyCode = normalizeCode(record.specialtyName);
const schoolName = getSchoolNameText(schoolCode, record.schoolName);
const specialtyName = getSpecialtyNameText(specialtyCode, record.specialtyName);
return {
index: index + 1,
studentName: record.studentName || '未知',
idNumber: record.idNumber || '',
maskedIdNumber: maskIdNumber(record.idNumber),
schoolName,
specialtyName,
isUnknownSchool: schoolName === '未知学校' || (!schoolCode && !record.schoolName),
isUnknownSpecialty: specialtyName === '未知专业' || (!specialtyCode && !record.specialtyName),
educationLevelCode,
educationLevel: getEducationLevelText(educationLevelCode),
learningFormCode,
learningForm: getLearningFormText(learningFormCode),
enrollmentDate: formatDate(record.enrollmentDate),
graduationDate: formatDate(record.graduationDate),
rawEnrollmentDate: record.enrollmentDate || '',
rawGraduationDate: record.graduationDate || '',
studyDuration: calculateStudyDuration(record.enrollmentDate, record.graduationDate),
};
})
);
const getTimestamp = (value) => {
const date = parseDate(value);
return date ? date.getTime() : null;
};
const orderedRecords = computed(() => {
if (enhancedRecords.value.length <= 1) return enhancedRecords.value;
return [...enhancedRecords.value].sort((a, b) => {
const startA =
getTimestamp(a.rawEnrollmentDate) ??
getTimestamp(a.rawGraduationDate) ??
Number.MAX_SAFE_INTEGER;
const startB =
getTimestamp(b.rawEnrollmentDate) ??
getTimestamp(b.rawGraduationDate) ??
Number.MAX_SAFE_INTEGER;
if (startA === startB) {
const endA = getTimestamp(a.rawGraduationDate) ?? Number.MAX_SAFE_INTEGER;
const endB = getTimestamp(b.rawGraduationDate) ?? Number.MAX_SAFE_INTEGER;
return endA - endB;
}
return startA - startB;
});
});
const educationRankMap = {
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 2.5,
};
const getEducationRank = (code) => {
const normalized = normalizeCode(code);
return educationRankMap[normalized] ?? 0;
};
const summaryRecord = computed(() => {
if (orderedRecords.value.length === 0) return null;
return orderedRecords.value.reduce((best, current) => {
if (!best) return current;
const currentRank = getEducationRank(current.educationLevelCode);
const bestRank = getEducationRank(best.educationLevelCode);
if (currentRank > bestRank) return current;
if (currentRank < bestRank) return best;
const currentGrad = getTimestamp(current.rawGraduationDate) ?? Number.NEGATIVE_INFINITY;
const bestGrad = getTimestamp(best.rawGraduationDate) ?? Number.NEGATIVE_INFINITY;
return currentGrad >= bestGrad
? current
: best;
}, null);
});
const latestGraduationText = computed(() => {
if (orderedRecords.value.length === 0) return '未知';
const latest = orderedRecords.value.reduce((latestRecord, current) => {
if (!latestRecord) return current;
const currentGrad = getTimestamp(current.rawGraduationDate) ?? Number.NEGATIVE_INFINITY;
const latestGrad = getTimestamp(latestRecord.rawGraduationDate) ?? Number.NEGATIVE_INFINITY;
return currentGrad >= latestGrad
? current
: latestRecord;
}, null);
return latest?.graduationDate || '未知';
});
const hasData = computed(() => orderedRecords.value.length > 0);
</script>
<template>
<div v-if="hasData" class="card max-w-4xl mx-auto">
<div class="flex flex-col gap-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 flex items-center justify-center">
<img :src="xlIcon" alt="学历信息" class="w-12 h-12" />
</div>
<div>
<h2 class="text-2xl font-bold text-gray-900">学历信息查询</h2>
</div>
</div>
<div class="flex flex-col items-start md:items-end gap-1">
<div class="text-lg font-semibold text-blue-600">
{{ orderedRecords.length }} 条记录
</div>
<div v-if="summaryRecord" class="summary-meta text-sm text-gray-500">
<span class="summary-meta__item">最高学历{{ summaryRecord.educationLevel }}</span>
<span v-if="latestGraduationText" class="summary-meta__divider">·</span>
<span class="summary-meta__item">最新毕业时间{{ latestGraduationText }}</span>
</div>
</div>
</div>
<div v-if="summaryRecord" class="summary-banner">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-full bg-white/80 flex items-center justify-center shadow-inner">
<img :src="xlIcon" alt="学历图标" class="w-10 h-10" />
</div>
<div>
<div class="text-xl font-semibold text-gray-900">{{ summaryRecord.studentName }}</div>
<div class="text-sm text-slate-600">身份证{{ summaryRecord.maskedIdNumber }}</div>
<div class="summary-highlight">
<span class="summary-highlight__badge">{{ summaryRecord.educationLevel }}</span>
<div class="summary-highlight__text flex flex-col gap-1">
<span class="flex items-center gap-2">
{{ summaryRecord.schoolName }}
<span v-if="summaryRecord.isUnknownSchool" class="unknown-hint">
<svg class="unknown-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
</span>
<span v-if="summaryRecord.isUnknownSchool" class="unknown-text">该学校名称信息未找到可能是学校已改名</span>
</div>
</div>
</div>
</div>
<!-- <div class="flex flex-wrap items-center gap-3">
<div class="summary-chip">
<span class="chip-label">入学时间</span>
<span class="chip-value">{{ summaryRecord.enrollmentDate }}</span>
</div>
<div class="summary-chip">
<span class="chip-label">毕业时间</span>
<span class="chip-value">{{ summaryRecord.graduationDate }}</span>
</div>
<div class="summary-chip">
<span class="chip-label">学习时长</span>
<span class="chip-value">{{ summaryRecord.studyDuration }}</span>
</div>
</div> -->
</div>
</div>
<div class="space-y-5">
<div v-for="record in orderedRecords" :key="record.index" class="record-card">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
<div class="record-header">
<div class="record-index">
{{ record.index }}
</div>
<div class="record-title">
<div class="record-title__name flex flex-col gap-1">
<span class="flex items-center gap-2">
{{ record.schoolName }}
<span v-if="record.isUnknownSchool" class="unknown-hint">
<svg class="unknown-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
</span>
<span v-if="record.isUnknownSchool" class="unknown-text">该学校名称信息未找到可能是学校已改名</span>
</div>
<div class="record-title__meta" v-if="record.enrollmentDate !== '未知' || record.graduationDate !== '未知'">
<span v-if="record.enrollmentDate !== '未知'">{{ record.enrollmentDate }}</span>
<span v-if="record.enrollmentDate !== '未知' && record.graduationDate !== '未知'"> - </span>
<span v-if="record.graduationDate !== '未知'">{{ record.graduationDate }}</span>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<span :class="['tag', getEducationLevelClass(record.educationLevelCode)]">
{{ record.educationLevel }}学历
</span>
<span :class="['tag', getLearningFormClass(record.learningFormCode)]">
学习形式{{ record.learningForm }}
</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<div class="info-block">
<div class="info-icon">
<img :src="zymcIcon" alt="专业名称" class="w-7 h-7" />
</div>
<div>
<div class="info-label">专业名称</div>
<div class="info-value flex flex-col gap-1">
<span class="flex items-center gap-2">
{{ record.specialtyName }}
<span v-if="record.isUnknownSpecialty" class="unknown-hint">
<svg class="unknown-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
</span>
<span v-if="record.isUnknownSpecialty" class="unknown-text">该专业名称信息未找到可能是该学校专业已受到变动</span>
</div>
</div>
</div>
<div class="info-block">
<div class="info-icon">
<img :src="xxxsIcon" alt="学习形式" class="w-7 h-7" />
</div>
<div>
<div class="info-label">学习形式</div>
<div class="info-value">
{{ record.learningForm }}
</div>
</div>
</div>
<div class="info-block">
<div class="info-icon">
<img :src="xxlxIcon" alt="入学时间" class="w-7 h-7" />
</div>
<div>
<div class="info-label">入学时间</div>
<div class="info-value">{{ record.enrollmentDate }}</div>
</div>
</div>
<div class="info-block">
<div class="info-icon">
<img :src="bysjIcon" alt="毕业时间" class="w-7 h-7" />
</div>
<div>
<div class="info-label">毕业时间</div>
<div class="info-value">{{ record.graduationDate }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card max-w-3xl mx-auto">
<div class="flex flex-col items-center py-12 text-center">
<div class="w-20 h-20 flex items-center justify-center mb-4">
<img :src="xlIcon" alt="学历图标" class="w-20 h-20 opacity-40" />
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">暂无学历记录</h3>
<p class="text-sm text-gray-500 max-w-md">
未查询到相关的学历信息可能是数据正在更新或尚未录入建议稍后重试或联系数据提供方确认
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.card {
padding: 1.5rem;
box-shadow: 0px 0px 24px 0px #3f3f3f0f;
border-radius: 12px;
background: white;
}
.summary-banner {
border-radius: 16px;
padding: 1.5rem;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(79, 70, 229, 0.12));
border: 1px solid rgba(59, 130, 246, 0.25);
}
.summary-chip {
display: inline-flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.3);
}
.chip-label {
font-size: 0.7rem;
color: #64748b;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.chip-value {
font-size: 0.95rem;
font-weight: 600;
color: #0f172a;
}
.summary-meta {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.summary-meta__divider {
color: #94a3b8;
}
.summary-highlight {
margin-top: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.summary-highlight__badge {
padding: 0.2rem 0.6rem;
border-radius: 9999px;
background: rgba(59, 130, 246, 0.12);
color: #2563eb;
font-size: 0.75rem;
font-weight: 600;
}
.summary-highlight__text {
font-size: 0.9rem;
color: #1e293b;
font-weight: 500;
}
.record-card {
position: relative;
padding: 1.5rem;
border-radius: 16px;
border: 1px solid rgba(226, 232, 240, 0.9);
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.06);
transition: all 0.25s ease;
}
.record-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.75), rgba(129, 140, 248, 0.75));
border-radius: 12px 12px 0 0;
}
.record-card:hover {
transform: translateY(-4px);
box-shadow: 0 16px 35px rgba(59, 130, 246, 0.16);
}
.record-header {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.record-index {
width: 3rem;
height: 3rem;
border-radius: 999px;
background: rgba(59, 130, 246, 0.18);
color: #2563eb;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.05rem;
flex-shrink: 0;
}
.record-title {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.record-title__name {
font-size: 1.125rem;
font-weight: 600;
color: #0f172a;
}
.record-title__meta {
font-size: 0.85rem;
color: #64748b;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
border-width: 1px;
border-style: solid;
}
.info-block {
display: flex;
gap: 0.75rem;
padding: 1rem;
border-radius: 12px;
background-color: rgba(248, 250, 252, 0.9);
border: 1px solid rgba(226, 232, 240, 0.8);
transition: border-color 0.2s ease, transform 0.2s ease;
}
.info-block:hover {
border-color: rgba(59, 130, 246, 0.3);
transform: translateY(-2px);
}
.info-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
background: rgba(59, 130, 246, 0.12);
display: flex;
align-items: center;
justify-content: center;
}
.info-label {
font-size: 0.75rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.info-value {
font-size: 1rem;
font-weight: 500;
color: #0f172a;
display: flex;
align-items: center;
gap: 0.5rem;
}
.unknown-hint {
display: inline-flex;
align-items: center;
}
.unknown-icon {
width: 1rem;
height: 1rem;
color: #f59e0b;
flex-shrink: 0;
}
.unknown-text {
font-size: 0.75rem;
color: #f59e0b;
line-height: 1.4;
margin-top: 0.125rem;
}
@media (max-width: 768px) {
.record-card {
padding: 1.25rem;
}
.info-block {
padding: 0.85rem;
}
}
</style>