report.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. <template>
  2. <view class="container">
  3. <view v-if="loading" class="loading-box">
  4. <text>加载报告中...</text>
  5. </view>
  6. <scroll-view v-else scroll-y class="scroll-body">
  7. <!-- 仪表盘区域 (Gauge Section) -->
  8. <view class="report-card gauge-card card-anim">
  9. <view class="gauge-container">
  10. <view class="gauge-main">
  11. <view class="gauge-base"></view>
  12. <!-- 仪表盘进度:总分 100 分对应 270 度 (从-135到135) -->
  13. <view class="gauge-progress-bar" :style="{ transform: 'rotate(' + (-135 + displayTotalScore * 2.7) + 'deg)' }"></view>
  14. <view class="gauge-ticks">
  15. <view v-for="i in 41" :key="i" class="tick" :style="{ transform: 'rotate(' + (-120 + (i-1)*6) + 'deg)' }"></view>
  16. </view>
  17. <view class="gauge-needle-box">
  18. <view class="needle" :style="{ transform: 'rotate(' + (-135 + displayTotalScore * 2.7) + 'deg)' }"></view>
  19. </view>
  20. <view class="score-display">
  21. <text class="num anim-num">{{ displayTotalScore }}</text>
  22. <text class="total">/100</text>
  23. </view>
  24. </view>
  25. </view>
  26. <view class="job-name">{{ evaluationName }}</view>
  27. <view class="report-meta">
  28. <text>测评报告分析</text>
  29. <text>{{ submitTime }}</text>
  30. </view>
  31. </view>
  32. <!-- 测评情况 (Radar Chart Section) -->
  33. <view class="report-card spider-card card-anim">
  34. <view class="section-header">
  35. <text class="title">维度分析</text>
  36. </view>
  37. <view class="spider-chart-box">
  38. <!-- 背景网格 -->
  39. <view class="pentagon-grids">
  40. <view v-for="i in [1,2,3,4,5]" :key="i" class="p-grid" :class="'pg-'+i" :style="{ clipPath: getPentagonPath(i * 20) }"></view>
  41. </view>
  42. <!-- 轴线 -->
  43. <view class="axes">
  44. <view v-for="(axis, index) in axesStyles" :key="index" class="axis" :style="axis"></view>
  45. </view>
  46. <!-- 动态数据区域 -->
  47. <view class="data-area">
  48. <view class="data-polygon" :style="{ clipPath: polygonClipPath }"></view>
  49. <view v-for="(node, index) in nodePositions" :key="index" class="node-point" :style="{ left: node.x + '%', top: node.y + '%' }">
  50. <text class="v-label" :style="node.labelStyle">{{ node.name }}</text>
  51. <text class="v-score" :style="node.scoreStyle">{{ node.score }}分</text>
  52. </view>
  53. </view>
  54. </view>
  55. </view>
  56. <!-- 3. 维度详情列表 -->
  57. <view class="report-card detail-card card-anim">
  58. <view class="section-header">
  59. <text class="title">维度详情</text>
  60. <text class="subtitle">点击查看答题详情</text>
  61. </view>
  62. <view class="ability-detail-list">
  63. <view
  64. v-for="(item, index) in abilityResults"
  65. :key="index"
  66. class="ability-detail-item"
  67. @click="viewAbilityDetail(item)"
  68. >
  69. <view class="item-left">
  70. <text class="a-name">{{ item.name }}</text>
  71. <view class="status-tag" :class="item.isPass ? 'pass' : 'fail'">
  72. {{ item.isPass ? '已达标' : '未达标' }}
  73. </view>
  74. </view>
  75. <view class="item-right">
  76. <text class="a-score">{{ item.score }}</text>
  77. <text class="a-unit">分</text>
  78. <image src="/static/icons/arrow-right.svg" class="arrow-icon"></image>
  79. </view>
  80. </view>
  81. </view>
  82. </view>
  83. </scroll-view>
  84. </view>
  85. </template>
  86. <script setup>
  87. import { ref, computed } from 'vue'
  88. import { onLoad } from '@dcloudio/uni-app'
  89. import { getEvaluationResult } from '../../api/assessment.js'
  90. const loading = ref(true)
  91. const evaluationId = ref('')
  92. const displayTotalScore = ref(0)
  93. const evaluationName = ref('测评报告')
  94. const submitTime = ref('')
  95. const radarData = ref({ categories: [], series: [] })
  96. const abilityResults = ref([]) // 存储详细维度数据
  97. onLoad(async (options) => {
  98. evaluationId.value = options.id || options.assessmentId || ''
  99. if (!evaluationId.value) {
  100. uni.showToast({ title: '参数错误', icon: 'none' })
  101. loading.value = false
  102. return
  103. }
  104. await loadData()
  105. })
  106. const loadData = async () => {
  107. if (!evaluationId.value || evaluationId.value === 'undefined') {
  108. loading.value = false
  109. return
  110. }
  111. try {
  112. const userInfo = uni.getStorageSync('userInfo') || {}
  113. const studentId = userInfo.studentId || userInfo.id
  114. if (!studentId) {
  115. uni.showToast({ title: '请先登录', icon: 'none' })
  116. return
  117. }
  118. const res = await getEvaluationResult(evaluationId.value, studentId)
  119. if (res.code === 200 && res.data) {
  120. const data = res.data
  121. abilityResults.value = data.abilityResults || []
  122. radarData.value = data.radarChart || { categories: [], series: [] }
  123. // 如果没有总分,取平均分
  124. if (data.totalScore) {
  125. displayTotalScore.value = Math.round(Number(data.totalScore))
  126. } else if (radarData.value.series.length > 0) {
  127. const sum = radarData.value.series.reduce((s, val) => s + (Number(val) || 0), 0)
  128. displayTotalScore.value = Math.round(sum / radarData.value.series.length)
  129. }
  130. evaluationName.value = data.evaluationName || '测评报告'
  131. submitTime.value = new Date().toLocaleString()
  132. }
  133. } catch (e) {
  134. console.error('加载报告失败:', e)
  135. } finally {
  136. loading.value = false
  137. }
  138. }
  139. // 查看某个能力的答题详情
  140. const viewAbilityDetail = (item) => {
  141. if (item.inquireLink) {
  142. // 存入缓存防止长链接截断
  143. uni.setStorageSync('temp_report_url', item.inquireLink)
  144. uni.navigateTo({
  145. url: '/pages/assessment/quiz?from=report'
  146. })
  147. } else {
  148. uni.showToast({ title: '暂无详情', icon: 'none' })
  149. }
  150. }
  151. // 核心算法:计算多边形顶点坐标
  152. const getPoint = (angle, radius) => {
  153. const x = 50 + radius * Math.cos((angle - 90) * Math.PI / 180)
  154. const y = 50 + radius * Math.sin((angle - 90) * Math.PI / 180)
  155. return { x, y }
  156. }
  157. // 绘制背景五边形/多边形路径
  158. const getPentagonPath = (radius) => {
  159. const count = Math.max(radarData.value.categories.length, 3)
  160. const points = []
  161. for (let i = 0; i < count; i++) {
  162. const { x, y } = getPoint(i * (360 / count), radius)
  163. points.push(`${x}% ${y}%`)
  164. }
  165. return `polygon(${points.join(', ')})`
  166. }
  167. // 轴线样式
  168. const axesStyles = computed(() => {
  169. const count = Math.max(radarData.value.categories.length, 3)
  170. return Array.from({ length: count }).map((_, i) => ({
  171. transform: `rotate(${(i * (360 / count)) - 90}deg)`
  172. }))
  173. })
  174. // 动态数据路径
  175. const polygonClipPath = computed(() => {
  176. const series = radarData.value.series
  177. const count = series.length
  178. if (count < 3) return 'none'
  179. const points = series.map((score, i) => {
  180. // 假设满分 100,雷达图半径 50,最小保证 20% 半径让图形始终可见
  181. const minRadius = 20
  182. const rawRadius = (Math.min(Number(score) || 0, 100) / 100) * 50
  183. const radius = Math.max(rawRadius, minRadius)
  184. const { x, y } = getPoint(i * (360 / count), radius)
  185. return `${x}% ${y}%`
  186. })
  187. return `polygon(${points.join(', ')})`
  188. })
  189. // 节点和标签位置
  190. const nodePositions = computed(() => {
  191. const { categories, series } = radarData.value
  192. const count = categories.length
  193. return categories.map((name, i) => {
  194. const score = series[i]
  195. // 节点位置(为了美观,稍微往外推一点),同样保证最小半径
  196. const minRadius = 20
  197. const rawRadius = (Math.min(Number(score) || 0, 100) / 100) * 50
  198. const radius = Math.max(rawRadius, minRadius)
  199. const pos = getPoint(i * (360 / count), radius)
  200. // 标签偏移逻辑
  201. const angle = i * (360 / count)
  202. let labelStyle = { position: 'absolute' }
  203. if (angle === 0) labelStyle.bottom = '40rpx'
  204. else if (angle < 180) labelStyle.left = '40rpx'
  205. else labelStyle.right = '40rpx'
  206. return {
  207. x: pos.x,
  208. y: pos.y,
  209. name,
  210. score,
  211. labelStyle
  212. }
  213. })
  214. })
  215. </script>
  216. <style lang="scss" scoped>
  217. .loading-box { display: flex; justify-content: center; align-items: center; height: 100vh; color: #999; }
  218. .container { min-height: 100vh; background-color: #F8F9FB; display: flex; flex-direction: column; }
  219. .scroll-body { flex: 1; padding: 30rpx 40rpx; box-sizing: border-box; }
  220. .report-card { background: #FFF; border-radius: 32rpx; padding: 50rpx 40rpx; margin-bottom: 30rpx; box-shadow: 0 8rpx 30rpx rgba(0,0,0,0.03); }
  221. /* 维度详情 */
  222. .detail-card {
  223. .section-header {
  224. display: flex;
  225. justify-content: space-between;
  226. align-items: center;
  227. margin-bottom: 30rpx;
  228. .subtitle { font-size: 24rpx; color: #999; }
  229. }
  230. }
  231. .ability-detail-list {
  232. display: flex;
  233. flex-direction: column;
  234. gap: 24rpx;
  235. }
  236. .ability-detail-item {
  237. display: flex;
  238. justify-content: space-between;
  239. align-items: center;
  240. padding: 30rpx;
  241. background: #F8F9FB;
  242. border-radius: 20rpx;
  243. &:active { opacity: 0.8; }
  244. .item-left {
  245. display: flex;
  246. flex-direction: column;
  247. gap: 10rpx;
  248. .a-name { font-size: 30rpx; font-weight: bold; color: #333; }
  249. .status-tag {
  250. font-size: 20rpx;
  251. padding: 4rpx 12rpx;
  252. border-radius: 6rpx;
  253. width: fit-content;
  254. &.pass { background: rgba(82, 196, 26, 0.1); color: #52c41a; }
  255. &.fail { background: rgba(255, 77, 79, 0.1); color: #ff4d4f; }
  256. }
  257. }
  258. .item-right {
  259. display: flex;
  260. align-items: center;
  261. .a-score { font-size: 40rpx; font-weight: bold; color: #1F6CFF; }
  262. .a-unit { font-size: 24rpx; color: #999; margin-left: 4rpx; margin-right: 10rpx; }
  263. .arrow-icon { width: 32rpx; height: 32rpx; opacity: 0.3; }
  264. }
  265. }
  266. /* 仪表盘 */
  267. .gauge-container { height: 440rpx; display: flex; justify-content: center; align-items: center; }
  268. .gauge-main {
  269. width: 400rpx; height: 400rpx; position: relative;
  270. .gauge-base { position: absolute; inset: 0; border: 20rpx solid #F0F2F5; border-radius: 50%; clip-path: polygon(0 100%, 0 0, 100% 0, 100% 100%, 50% 50%); transform: rotate(135deg); }
  271. .gauge-progress-bar { position: absolute; inset: 0; border: 20rpx solid #1F6CFF; border-radius: 50%; clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%); transition: transform 1s cubic-bezier(0.4, 0, 0.2, 1); }
  272. .gauge-ticks { position: absolute; inset: 30rpx; .tick { position: absolute; top: 0; left: 50%; width: 2rpx; height: 12rpx; background: #EEE; transform-origin: center 130rpx; } }
  273. .gauge-needle-box { position: absolute; inset: 0; display: flex; justify-content: center; .needle { width: 16rpx; height: 110rpx; background: #1F6CFF; clip-path: polygon(50% 0, 100% 100%, 0 100%); margin-top: 50rpx; transform-origin: center 110rpx; transition: transform 1s cubic-bezier(0.4, 0, 0.2, 1); } }
  274. .score-display { position: absolute; bottom: 60rpx; left: 50%; transform: translateX(-50%); text-align: center; .num { font-size: 64rpx; font-weight: bold; color: #1F6CFF; display: block; line-height: 1; margin-bottom: 6rpx; } .total { font-size: 24rpx; color: #999; } }
  275. }
  276. .job-name { font-size: 40rpx; font-weight: bold; color: #1A1A1A; text-align: center; margin-top: 40rpx; margin-bottom: 30rpx; }
  277. .report-meta { display: flex; justify-content: space-between; font-size: 26rpx; color: #999; }
  278. /* 雷达图核心容器 */
  279. .spider-chart-box { height: 600rpx; width: 100%; position: relative; margin-top: 40rpx; display: flex; justify-content: center; align-items: center; }
  280. .pentagon-grids { position: absolute; inset: 0; display: flex; justify-content: center; align-items: center; .p-grid { position: absolute; width: 100%; height: 100%; background: none; border: 1.5rpx solid #F0F2F5; box-sizing: border-box; } }
  281. .axes { position: absolute; inset: 0; .axis { position: absolute; top: 50%; left: 50%; width: 50%; height: 1rpx; background: #F0F2F5; transform-origin: left center; } }
  282. .data-area {
  283. position: absolute; inset: 0;
  284. .data-polygon { position: absolute; inset: 0; background: rgba(31, 108, 255, 0.15); border: 3rpx solid #1F6CFF; transition: all 1s ease-in-out; }
  285. .node-point {
  286. position: absolute; width: 0; height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
  287. &::after { content: ''; position: absolute; width: 12rpx; height: 12rpx; background: #1F6CFF; border-radius: 50%; border: 4rpx solid #FFF; box-shadow: 0 0 10rpx rgba(31,108,255,0.3); transform: translate(-50%, -50%); }
  288. .v-label { font-size: 24rpx; color: #333; white-space: nowrap; }
  289. .v-score { font-size: 20rpx; color: #1F6CFF; white-space: nowrap; margin-top: 40rpx; }
  290. }
  291. }
  292. .card-anim { animation: slideUp 0.8s ease-out; }
  293. @keyframes slideUp { from { transform: translateY(30rpx); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
  294. </style>