resume_view.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. <template>
  2. <view class="resume-container">
  3. <view class="loading-box" v-if="isLoading">
  4. <view class="loading-spinner"></view>
  5. <text class="loading-text">加载中...</text>
  6. </view>
  7. <scroll-view class="resume-scroll" scroll-y v-else>
  8. <!-- 个人基础卡片 -->
  9. <view class="section-card header-card">
  10. <view class="header-main">
  11. <view class="header-info">
  12. <view class="name-row">
  13. <text class="name">{{ studentData.name || '加载中...' }}</text>
  14. <text class="status-badge" v-if="jobTypeLabel && availabilityLabel">{{ jobTypeLabel }} · {{ availabilityLabel }}</text>
  15. </view>
  16. <view class="summary" v-if="workYears || studentData.education || studentData.schoolName">
  17. <text v-if="workYears">{{ workYears }}年经验</text>
  18. <text v-if="workYears && educationLabel"> / </text>
  19. <text v-if="educationLabel">{{ educationLabel }}</text>
  20. <text v-if="(workYears || educationLabel) && studentData.schoolName"> / </text>
  21. <text v-if="studentData.schoolName">{{ studentData.schoolName }}</text>
  22. </view>
  23. </view>
  24. <image class="avatar" :src="studentData.avatarUrl || '/static/images/hr_avatar.svg'" mode="aspectFill"></image>
  25. </view>
  26. <view class="contact-grid">
  27. <view class="contact-item" v-if="studentData.mobile">
  28. <image src="/static/icons/resume-phone.svg" class="mini-icon" mode="aspectFit"></image>
  29. <text>{{ maskPhone(studentData.mobile) }}</text>
  30. </view>
  31. <view class="contact-item" v-if="studentData.email">
  32. <image src="/static/icons/resume-mail.svg" class="mini-icon" mode="aspectFit"></image>
  33. <text>{{ studentData.email }}</text>
  34. </view>
  35. </view>
  36. </view>
  37. <!-- 个人信息 -->
  38. <view class="section-card" v-if="hasPersonalInfo">
  39. <view class="section-title">个人信息</view>
  40. <view class="info-list">
  41. <view class="info-row" v-if="studentData.gender">
  42. <text class="label">性别</text>
  43. <text class="value">{{ genderLabel }}</text>
  44. </view>
  45. <view class="info-row" v-if="birthDate">
  46. <text class="label">出生日期</text>
  47. <text class="value">{{ birthDate }}</text>
  48. </view>
  49. <view class="info-row" v-if="educationLabel">
  50. <text class="label">最高学历</text>
  51. <text class="value">{{ educationLabel }}</text>
  52. </view>
  53. <view class="info-row" v-if="studentData.schoolName">
  54. <text class="label">毕业院校</text>
  55. <text class="value">{{ studentData.schoolName }}</text>
  56. </view>
  57. </view>
  58. </view>
  59. <!-- 求职意向 -->
  60. <view class="section-card" v-if="jobIntentions.length > 0">
  61. <view class="section-title">求职意向</view>
  62. <view class="intent-box">
  63. <view class="tag-group">
  64. <text class="intent-tag" v-for="(intent, idx) in jobIntentions" :key="idx">{{ intent }}</text>
  65. </view>
  66. </view>
  67. </view>
  68. <!-- 工作经历 -->
  69. <view class="section-card" v-if="studentData.experienceList && studentData.experienceList.length > 0">
  70. <view class="section-title">工作经历</view>
  71. <view class="timeline">
  72. <view class="timeline-item" v-for="(exp, idx) in studentData.experienceList" :key="exp.id || idx">
  73. <view class="dot"></view>
  74. <view class="time-range">{{ formatTimeRange(exp.startTime, exp.endTime) }}</view>
  75. <view class="comp-name">{{ exp.isHidden === 1 ? '***(已屏蔽)' : exp.company }}</view>
  76. <view class="pos-name">{{ exp.jobTitle }}<text v-if="exp.isInternship === 1" style="font-size: 24rpx; margin-left: 8rpx;">(实习)</text></view>
  77. <view class="job-desc" v-if="exp.workContent">{{ exp.workContent }}</view>
  78. </view>
  79. </view>
  80. </view>
  81. <!-- 项目经历 -->
  82. <view class="section-card" v-if="studentData.projectList && studentData.projectList.length > 0">
  83. <view class="section-title">项目经历</view>
  84. <view class="project-item" v-for="(proj, idx) in studentData.projectList" :key="proj.id || idx">
  85. <view class="p-header">
  86. <text class="p-name">{{ proj.projectName }}</text>
  87. <text class="p-time">{{ formatTimeRange(proj.startTime, proj.endTime) }}</text>
  88. </view>
  89. <view class="p-role" v-if="proj.role">{{ proj.role }}</view>
  90. <view class="p-content" v-if="proj.description">{{ proj.description }}</view>
  91. <view class="p-content" v-if="proj.achievement" style="margin-top: 12rpx;">成果:{{ proj.achievement }}</view>
  92. </view>
  93. </view>
  94. <!-- 教育经历 -->
  95. <view class="section-card" v-if="studentData.educationList && studentData.educationList.length > 0">
  96. <view class="section-title">教育经历</view>
  97. <view class="edu-item" v-for="(edu, idx) in studentData.educationList" :key="edu.id || idx">
  98. <view class="edu-header">
  99. <text class="school">{{ edu.school }}</text>
  100. <text class="e-time">{{ formatTimeRange(edu.startTime, edu.endTime) }}</text>
  101. </view>
  102. <view class="major">
  103. <text v-if="edu.major">{{ edu.major }}</text>
  104. <text v-if="edu.major && (edu.education || edu.educationType)"> · </text>
  105. <text v-if="edu.education">{{ getEducationLabel(edu.education) }}</text>
  106. <text v-if="edu.education && edu.educationType"> · </text>
  107. <text v-if="edu.educationType">{{ edu.educationType }}</text>
  108. </view>
  109. <view class="p-content" v-if="edu.campusExperience" style="margin-top: 12rpx; font-size: 26rpx; color: #666;">{{ edu.campusExperience }}</view>
  110. </view>
  111. </view>
  112. <!-- 底部撑开 -->
  113. <view class="bottom-spacer"></view>
  114. </scroll-view>
  115. <!-- 底部操作按钮 -->
  116. <view class="fixed-footer">
  117. <button class="edit-btn" @click="handleEditResume">编辑简历</button>
  118. </view>
  119. </view>
  120. </template>
  121. <script setup>
  122. import { ref, computed, onMounted } from 'vue';
  123. import { onShow } from '@dcloudio/uni-app';
  124. import { getStudent } from '../../api/student.js';
  125. const studentData = ref({
  126. name: '',
  127. mobile: '',
  128. email: '',
  129. gender: '',
  130. genderLabel: '',
  131. idCardNumber: '',
  132. avatarUrl: '',
  133. schoolName: '',
  134. education: '',
  135. educationLabel: '',
  136. availabilityLabel: '',
  137. jobType: '',
  138. jobTypeLabel: '',
  139. availability: '',
  140. jobIntention: '',
  141. educationList: [],
  142. experienceList: [],
  143. projectList: []
  144. });
  145. const isLoading = ref(true);
  146. // 学历字典映射
  147. const educationMap = {
  148. '1': '初中及以下',
  149. '2': '高中/中专',
  150. '3': '大专',
  151. '4': '本科',
  152. '5': '硕士',
  153. '6': '博士',
  154. };
  155. // 求职类型映射
  156. const jobTypeMap = {
  157. '1': '全职',
  158. '2': '实习',
  159. '3': '兼职',
  160. };
  161. // 性别映射
  162. const genderMap = {
  163. '0': '男',
  164. '1': '女',
  165. '2': '未知'
  166. };
  167. const normalizeGenderValue = (value) => {
  168. if (value === undefined || value === null || value === '') return '';
  169. const gender = String(value).trim().toUpperCase();
  170. if (gender === 'M') return '0';
  171. if (gender === 'F') return '1';
  172. return gender;
  173. };
  174. // 计算属性
  175. const educationLabel = computed(() => {
  176. if (studentData.value.educationList && studentData.value.educationList.length > 0) {
  177. const educationLevel = { '初中及以下': 1, '高中': 2, '中专': 2, '大专': 3, '本科': 4, '硕士': 5, '博士': 6 };
  178. let highestEdu = studentData.value.educationList[0];
  179. let maxLevel = educationLevel[highestEdu.education] || 0;
  180. for (const edu of studentData.value.educationList) {
  181. const level = educationLevel[edu.education] || 0;
  182. if (level > maxLevel) { maxLevel = level; highestEdu = edu; }
  183. }
  184. const label = highestEdu.education || '';
  185. return label + (highestEdu.educationType ? ' · ' + highestEdu.educationType : '');
  186. }
  187. if (studentData.value.educationLabel) return studentData.value.educationLabel;
  188. const val = studentData.value.education;
  189. if (!val || val === '0' || val === 0) return '';
  190. return educationMap[val] || (/^\d+$/.test(String(val)) ? '' : val);
  191. });
  192. const jobTypeLabel = computed(() => {
  193. if (studentData.value.jobTypeLabel) return studentData.value.jobTypeLabel;
  194. const val = studentData.value.jobType;
  195. if (!val) return '';
  196. return jobTypeMap[val] || val;
  197. });
  198. const availabilityLabel = computed(() => {
  199. return studentData.value.availabilityLabel || studentData.value.availability || '';
  200. });
  201. const genderLabel = computed(() => {
  202. if (studentData.value.genderLabel) return studentData.value.genderLabel;
  203. const g = normalizeGenderValue(studentData.value.gender);
  204. if (!g) return '';
  205. return genderMap[g] || g;
  206. });
  207. const jobIntentions = computed(() => {
  208. if (!studentData.value.jobIntention) return [];
  209. // jobIntention 可能存的是字典值(如 "1,2"),需要转换
  210. const items = studentData.value.jobIntention.split(',').filter(item => item.trim());
  211. const intentionMap = { '1': '审计', '2': '咨询', '3': '税务', '4': '财务', '5': '评估' };
  212. return items.map(item => intentionMap[item.trim()] || item.trim());
  213. });
  214. const birthDate = computed(() => {
  215. if (!studentData.value.idCardNumber || studentData.value.idCardNumber.length < 14) return '';
  216. const year = studentData.value.idCardNumber.substring(6, 10);
  217. const month = studentData.value.idCardNumber.substring(10, 12);
  218. const day = studentData.value.idCardNumber.substring(12, 14);
  219. return `${year}-${month}-${day}`;
  220. });
  221. const workYears = computed(() => {
  222. if (!studentData.value.experienceList || studentData.value.experienceList.length === 0) return 0;
  223. let totalMonths = 0;
  224. studentData.value.experienceList.forEach(exp => {
  225. if (exp.startTime && exp.endTime) {
  226. const start = new Date(exp.startTime);
  227. const end = exp.endTime === '至今' ? new Date() : new Date(exp.endTime);
  228. const months = (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth());
  229. totalMonths += months;
  230. }
  231. });
  232. return Math.floor(totalMonths / 12);
  233. });
  234. const hasPersonalInfo = computed(() => {
  235. return studentData.value.gender || birthDate.value || educationLabel.value || studentData.value.schoolName;
  236. });
  237. // 工具函数
  238. const maskPhone = (phone) => {
  239. if (!phone || phone.length < 11) return phone;
  240. return phone.substring(0, 3) + '****' + phone.substring(7);
  241. };
  242. const formatTimeRange = (start, end) => {
  243. if (!start) return '';
  244. const formatDate = (dateStr) => {
  245. if (!dateStr) return '';
  246. if (dateStr === '至今') return '至今';
  247. const date = new Date(dateStr);
  248. return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}`;
  249. };
  250. return `${formatDate(start)} - ${formatDate(end) || '至今'}`;
  251. };
  252. const getEducationLabel = (education) => {
  253. return educationMap[education] || education || '';
  254. };
  255. const handleEditResume = () => {
  256. uni.navigateTo({ url: '/pages/profile/profile?editMode=1' });
  257. };
  258. // 页面加载时获取数据
  259. const fetchResumeData = async () => {
  260. const userInfo = uni.getStorageSync('userInfo');
  261. if (!userInfo || !userInfo.studentId) {
  262. uni.showToast({ title: '请先登录', icon: 'none' });
  263. setTimeout(() => {
  264. uni.reLaunch({ url: '/pages/login/login' });
  265. }, 1500);
  266. return;
  267. }
  268. try {
  269. isLoading.value = true;
  270. const res = await getStudent(userInfo.studentId);
  271. if (res && res.data) {
  272. studentData.value = {
  273. ...studentData.value,
  274. ...res.data,
  275. educationList: res.data.educationList || [],
  276. experienceList: res.data.experienceList || [],
  277. projectList: res.data.projectList || []
  278. };
  279. } else {
  280. uni.showToast({ title: '获取简历数据失败', icon: 'none' });
  281. }
  282. } catch (err) {
  283. console.error('获取简历数据异常', err);
  284. uni.showToast({ title: '网络异常,请重试', icon: 'none' });
  285. } finally {
  286. isLoading.value = false;
  287. uni.hideLoading();
  288. }
  289. };
  290. onMounted(() => {
  291. fetchResumeData();
  292. });
  293. onShow(() => {
  294. fetchResumeData();
  295. });
  296. </script>
  297. <style lang="scss" scoped>
  298. .resume-container {
  299. min-height: 100vh;
  300. background-color: #F6F8FB;
  301. }
  302. .loading-box {
  303. display: flex;
  304. flex-direction: column;
  305. align-items: center;
  306. justify-content: center;
  307. height: 80vh;
  308. .loading-spinner {
  309. width: 60rpx;
  310. height: 60rpx;
  311. border: 6rpx solid #f3f3f3;
  312. border-top: 6rpx solid #1F6CFF;
  313. border-radius: 50%;
  314. animation: spin 1s linear infinite;
  315. }
  316. .loading-text {
  317. margin-top: 20rpx;
  318. font-size: 28rpx;
  319. color: #999;
  320. }
  321. }
  322. @keyframes spin {
  323. 0% { transform: rotate(0deg); }
  324. 100% { transform: rotate(360deg); }
  325. }
  326. .resume-scroll {
  327. height: 100vh;
  328. }
  329. .section-card {
  330. margin: 24rpx 30rpx;
  331. background: #FFF;
  332. border-radius: 24rpx;
  333. padding: 32rpx;
  334. box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.03);
  335. .section-title {
  336. font-size: 32rpx;
  337. font-weight: bold;
  338. color: #1A1A1A;
  339. margin-bottom: 24rpx;
  340. padding-left: 16rpx;
  341. border-left: 6rpx solid #1F6CFF;
  342. }
  343. }
  344. .header-card {
  345. margin-top: 30rpx;
  346. .header-main {
  347. display: flex;
  348. justify-content: space-between;
  349. align-items: flex-start;
  350. margin-bottom: 30rpx;
  351. .name { font-size: 48rpx; font-weight: bold; color: #1A1A1A; margin-right: 20rpx; }
  352. .status-badge { font-size: 20rpx; color: #1F6CFF; background: #E9F1FF; padding: 4rpx 12rpx; border-radius: 4rpx; }
  353. .summary { font-size: 26rpx; color: #666; margin-top: 12rpx; }
  354. .avatar { width: 120rpx; height: 120rpx; border-radius: 50%; background: #F0F0F0; }
  355. }
  356. .contact-grid {
  357. display: flex; gap: 40rpx;
  358. .contact-item {
  359. display: flex; align-items: center; font-size: 24rpx; color: #999;
  360. .mini-icon { width: 28rpx; height: 28rpx; margin-right: 8rpx; }
  361. }
  362. }
  363. }
  364. .info-list {
  365. .info-row {
  366. display: flex; justify-content: space-between; margin-bottom: 20rpx;
  367. &:last-child { margin-bottom: 0; }
  368. .label { font-size: 28rpx; color: #999; }
  369. .value { font-size: 28rpx; color: #333; font-weight: 500; }
  370. }
  371. }
  372. .intent-box {
  373. .tag-group {
  374. display: flex; gap: 16rpx; margin-bottom: 16rpx;
  375. .intent-tag { font-size: 26rpx; color: #1F6CFF; border: 1rpx solid #1F6CFF; padding: 4rpx 20rpx; border-radius: 6rpx; }
  376. }
  377. .intent-detail { font-size: 26rpx; color: #666; }
  378. }
  379. .timeline {
  380. .timeline-item {
  381. position: relative;
  382. padding-left: 40rpx;
  383. padding-bottom: 40rpx;
  384. border-left: 2rpx dashed #DDD;
  385. &:last-child { padding-bottom: 0; border-left: none; }
  386. .dot {
  387. position: absolute; left: -7rpx; top: 10rpx;
  388. width: 12rpx; height: 12rpx; background: #1F6CFF; border-radius: 50%;
  389. }
  390. .time-range { font-size: 24rpx; color: #999; margin-bottom: 12rpx; }
  391. .comp-name { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 8rpx; }
  392. .pos-name { font-size: 28rpx; color: #1F6CFF; margin-bottom: 12rpx; }
  393. .job-desc { font-size: 26rpx; color: #666; line-height: 1.6; }
  394. }
  395. }
  396. .project-item, .edu-item {
  397. .p-header, .edu-header {
  398. display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx;
  399. .p-name, .school { font-size: 30rpx; font-weight: bold; color: #333; }
  400. .p-time, .e-time { font-size: 24rpx; color: #999; }
  401. }
  402. .p-role, .major { font-size: 28rpx; color: #1F6CFF; margin-bottom: 12rpx; }
  403. .p-content { font-size: 26rpx; color: #666; line-height: 1.6; }
  404. }
  405. .bottom-spacer { height: 180rpx; }
  406. .fixed-footer {
  407. position: fixed; left: 0; right: 0; bottom: 0;
  408. background: rgba(255,255,255,0.9);
  409. backdrop-filter: blur(10px);
  410. padding: 24rpx 40rpx calc(24rpx + env(safe-area-inset-bottom));
  411. box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.05);
  412. .edit-btn {
  413. height: 72rpx; line-height: 72rpx; background: #1F6CFF; color: #FFF;
  414. font-size: 28rpx; font-weight: bold; border-radius: 36rpx;
  415. &::after { border: none; }
  416. }
  417. }
  418. </style>