relation-detail.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. <template>
  2. <view class="relation-detail-container">
  3. <!-- 自定义导航栏 -->
  4. <view class="custom-nav">
  5. <view class="nav-back" @click="goBack">‹ 返回</view>
  6. <text class="nav-title">关系详情</text>
  7. </view>
  8. <!-- 加载状态 -->
  9. <view class="loading-state" v-if="loading">
  10. <view class="loading-spinner"></view>
  11. <text>加载中...</text>
  12. </view>
  13. <!-- 错误状态 -->
  14. <view class="error-state" v-if="error && !loading">
  15. <text>{{ error }}</text>
  16. <button class="retry-btn" @click="loadData">重试</button>
  17. </view>
  18. <!-- 详情内容 -->
  19. <view class="detail-content" v-if="relation && !loading">
  20. <!-- 关系评分卡片 -->
  21. <view class="score-card">
  22. <view class="score-circle">
  23. <canvas id="scoreCanvas" class="score-canvas"></canvas>
  24. <view class="score-content">
  25. <text class="score-value" :class="!relation.score ? '' : (relation.score >= 80 ? 'score-good' : (relation.score >= 60 ? 'score-medium' : 'score-low'))">{{ relation.score }}</text>
  26. <text class="score-label">关系质量分</text>
  27. </view>
  28. </view>
  29. <view class="score-level">
  30. <text class="level-text">{{ getScoreLevel(relation.score) }}</text>
  31. </view>
  32. </view>
  33. <!-- 双方信息 -->
  34. <view class="members-card">
  35. <view class="card-header">
  36. <text class="card-title">关系双方</text>
  37. </view>
  38. <view class="members-row">
  39. <view class="member-item">
  40. <view class="member-avatar" :style="{ backgroundColor: member1Color }">
  41. <text class="member-avatar-text">{{ relation.fromMemberName ? relation.fromMemberName.charAt(0) : 'A' }}</text>
  42. </view>
  43. <text class="member-name">{{ relation.fromMemberName }}</text>
  44. <text class="member-role">{{ getRoleText(relation.fromMemberRole) }}</text>
  45. </view>
  46. <view class="relation-icon">
  47. <text>↔</text>
  48. </view>
  49. <view class="member-item">
  50. <view class="member-avatar" :style="{ backgroundColor: member2Color }">
  51. <text class="member-avatar-text">{{ relation.toMemberName ? relation.toMemberName.charAt(0) : 'B' }}</text>
  52. </view>
  53. <text class="member-name">{{ relation.toMemberName }}</text>
  54. <text class="member-role">{{ getRoleText(relation.toMemberRole) }}</text>
  55. </view>
  56. </view>
  57. </view>
  58. <!-- 维度分解 -->
  59. <view class="dimensions-card">
  60. <view class="card-header">
  61. <text class="card-title">维度分解</text>
  62. </view>
  63. <view class="dimension-list">
  64. <view class="dimension-item" v-for="dim in dimensions" :key="dim.type">
  65. <view class="dim-info">
  66. <text class="dim-label">{{ dim.label }}</text>
  67. <text class="dim-desc">{{ dim.description }}</text>
  68. </view>
  69. <view class="dim-bar">
  70. <view class="dim-fill" :style="{ width: dim.value + '%', backgroundColor: dim.color }"></view>
  71. </view>
  72. <text class="dim-value">{{ dim.value }}</text>
  73. </view>
  74. </view>
  75. </view>
  76. <!-- 关系趋势 -->
  77. <view class="trend-card" v-if="relation.trend && relation.trend.length > 0">
  78. <view class="card-header">
  79. <text class="card-title">关系趋势</text>
  80. </view>
  81. <view class="trend-chart">
  82. <canvas id="trendCanvas" class="trend-canvas"></canvas>
  83. </view>
  84. </view>
  85. <!-- 改进建议 -->
  86. <view class="advice-card" v-if="relation.advice">
  87. <view class="card-header">
  88. <text class="card-title">改进建议</text>
  89. </view>
  90. <view class="advice-content">
  91. <text class="advice-text">{{ relation.advice }}</text>
  92. </view>
  93. </view>
  94. <!-- 操作按钮 -->
  95. <view class="action-buttons">
  96. <button class="action-btn primary" @click="startQuestionnaire">填写关系问卷</button>
  97. <button class="action-btn secondary" @click="viewInteractionLog">查看互动记录</button>
  98. </view>
  99. </view>
  100. </view>
  101. </template>
  102. <script>
  103. import { mapActions } from 'vuex'
  104. export default {
  105. data: function() {
  106. return {
  107. memberId: '',
  108. relation: null,
  109. loading: false,
  110. error: null,
  111. scoreCanvasContext: null,
  112. trendCanvasContext: null
  113. }
  114. },
  115. computed: {
  116. member1Color: function() {
  117. if (!this.relation || !this.relation.fromMemberRole) {
  118. return '#6B7280'
  119. }
  120. var colorMap = {
  121. 'parent': '#F97316',
  122. 'child': '#3B82F6',
  123. 'teacher': '#8B5CF6'
  124. }
  125. return colorMap[this.relation.fromMemberRole] || '#6B7280'
  126. },
  127. member2Color: function() {
  128. if (!this.relation || !this.relation.toMemberRole) {
  129. return '#6B7280'
  130. }
  131. var colorMap = {
  132. 'parent': '#F97316',
  133. 'child': '#3B82F6',
  134. 'teacher': '#8B5CF6'
  135. }
  136. return colorMap[this.relation.toMemberRole] || '#6B7280'
  137. },
  138. dimensions: function() {
  139. if (!this.relation || !this.relation.dimensions) {
  140. return []
  141. }
  142. var dimMap = {
  143. 'communication': { label: '沟通', description: '沟通频率与质量', color: '#10B981' },
  144. 'trust': { label: '信任', description: '相互信任程度', color: '#3B82F6' },
  145. 'understanding': { label: '理解', description: '相互理解程度', color: '#8B5CF6' },
  146. 'support': { label: '支持', description: '相互支持力度', color: '#F59E0B' },
  147. 'harmony': { label: '和谐', description: '关系和谐度', color: '#F97316' }
  148. }
  149. var result = []
  150. for (var key in this.relation.dimensions) {
  151. var value = this.relation.dimensions[key]
  152. var info = dimMap[key] || { label: key, description: '', color: '#6B7280' }
  153. result.push({
  154. type: key,
  155. label: info.label,
  156. description: info.description,
  157. value: value,
  158. color: info.color
  159. })
  160. }
  161. return result
  162. }
  163. },
  164. onLoad: function(options) {
  165. if (options && options.memberId) {
  166. this.memberId = options.memberId
  167. this.memberId2 = options.memberId2 || null
  168. this.loadData()
  169. }
  170. },
  171. onReady: function() {
  172. if (!this.loading && this.relation) {
  173. this.drawScoreCanvas()
  174. this.drawTrendCanvas()
  175. }
  176. },
  177. methods: {
  178. ...mapActions('tianpan', ['loadCompatibility']),
  179. loadData: function() {
  180. this.loading = true
  181. this.error = null
  182. if (!this.memberId) {
  183. this.error = '参数错误'
  184. this.loading = false
  185. return
  186. }
  187. var memberId2 = this.memberId2 || parseInt(this.memberId)
  188. this.loadCompatibility({ member1Id: parseInt(this.memberId), member2Id: parseInt(memberId2) })
  189. .then(this.setRelationData)
  190. .catch(this.handleError)
  191. .finally(function() {
  192. this.loading = false
  193. }.bind(this))
  194. },
  195. setRelationData: function(data) {
  196. if (data) {
  197. this.relation = data
  198. this.$nextTick(function() {
  199. this.drawScoreCanvas()
  200. this.drawTrendCanvas()
  201. }.bind(this))
  202. }
  203. },
  204. handleError: function(e) {
  205. this.error = e.message || '加载失败'
  206. },
  207. drawScoreCanvas: function() {
  208. var query = uni.createSelectorQuery()
  209. query.select('#scoreCanvas')
  210. .fields({ node: true, size: true })
  211. .exec(this.onScoreCanvasReady)
  212. },
  213. onScoreCanvasReady: function(res) {
  214. if (!res || !res[0] || !res[0].node) {
  215. return
  216. }
  217. var ctx = res[0].node.getContext('2d')
  218. var dpr = uni.getWindowInfo().pixelRatio
  219. this.scoreCanvasContext = ctx
  220. this.scoreCanvasContext.scale(dpr, dpr)
  221. var w = res[0].width / dpr
  222. var h = res[0].height / dpr
  223. var cx = w / 2
  224. var cy = h / 2
  225. var r = Math.min(w, h) / 2 - 10
  226. // 清空画布
  227. ctx.clearRect(0, 0, w, h)
  228. // 绘制背景圆
  229. ctx.beginPath()
  230. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  231. ctx.strokeStyle = '#E5E7EB'
  232. ctx.lineWidth = 20
  233. ctx.stroke()
  234. // 绘制进度圆
  235. var score = this.relation && this.relation.score ? this.relation.score : 0
  236. var startAngle = -Math.PI / 2
  237. var endAngle = startAngle + (score / 100) * Math.PI * 2
  238. ctx.beginPath()
  239. ctx.arc(cx, cy, r, startAngle, endAngle)
  240. ctx.strokeStyle = this.getScoreColor(score)
  241. ctx.lineWidth = 20
  242. ctx.lineCap = 'round'
  243. ctx.stroke()
  244. },
  245. drawTrendCanvas: function() {
  246. if (!this.relation || !this.relation.trend || this.relation.trend.length === 0) {
  247. return
  248. }
  249. var query = uni.createSelectorQuery()
  250. query.select('#trendCanvas')
  251. .fields({ node: true, size: true })
  252. .exec(this.onTrendCanvasReady)
  253. },
  254. onTrendCanvasReady: function(res) {
  255. if (!res || !res[0] || !res[0].node) {
  256. return
  257. }
  258. var ctx = res[0].node.getContext('2d')
  259. var dpr = uni.getWindowInfo().pixelRatio
  260. this.trendCanvasContext = ctx
  261. this.trendCanvasContext.scale(dpr, dpr)
  262. var w = res[0].width / dpr
  263. var h = res[0].height / dpr
  264. // 清空画布
  265. ctx.clearRect(0, 0, w, h)
  266. var trend = this.relation.trend
  267. var padding = 20
  268. var chartW = w - padding * 2
  269. var chartH = h - padding * 2
  270. // 绘制坐标轴
  271. ctx.beginPath()
  272. ctx.moveTo(padding, padding)
  273. ctx.lineTo(padding, h - padding)
  274. ctx.lineTo(w - padding, h - padding)
  275. ctx.strokeStyle = '#E5E7EB'
  276. ctx.lineWidth = 1
  277. ctx.stroke()
  278. // 绘制折线
  279. if (trend.length < 2) {
  280. return
  281. }
  282. var stepX = chartW / (trend.length - 1)
  283. var maxScore = 100
  284. ctx.beginPath()
  285. for (var i = 0; i < trend.length; i++) {
  286. var point = trend[i]
  287. var x = padding + i * stepX
  288. var y = h - padding - (point.score / maxScore) * chartH
  289. if (i === 0) {
  290. ctx.moveTo(x, y)
  291. } else {
  292. ctx.lineTo(x, y)
  293. }
  294. }
  295. ctx.strokeStyle = '#F97316'
  296. ctx.lineWidth = 2
  297. ctx.stroke()
  298. // 绘制数据点
  299. for (var j = 0; j < trend.length; j++) {
  300. var point2 = trend[j]
  301. var x2 = padding + j * stepX
  302. var y2 = h - padding - (point2.score / maxScore) * chartH
  303. ctx.beginPath()
  304. ctx.arc(x2, y2, 4, 0, Math.PI * 2)
  305. ctx.fillStyle = '#F97316'
  306. ctx.fill()
  307. }
  308. },
  309. getScoreColor: function(score) {
  310. if (!score) {
  311. return '#6B7280'
  312. }
  313. if (score >= 80) {
  314. return '#10B981'
  315. } else if (score >= 60) {
  316. return '#F59E0B'
  317. } else {
  318. return '#EF4444'
  319. }
  320. },
  321. getScoreClass: function(score) {
  322. if (!score) {
  323. return ''
  324. }
  325. if (score >= 80) {
  326. return 'score-good'
  327. } else if (score >= 60) {
  328. return 'score-medium'
  329. } else {
  330. return 'score-low'
  331. }
  332. },
  333. getScoreLevel: function(score) {
  334. if (!score) {
  335. return '未知'
  336. }
  337. if (score >= 90) {
  338. return '卓越'
  339. } else if (score >= 80) {
  340. return '优秀'
  341. } else if (score >= 70) {
  342. return '良好'
  343. } else if (score >= 60) {
  344. return '合格'
  345. } else {
  346. return '待改善'
  347. }
  348. },
  349. getRoleText: function(role) {
  350. var roleMap = {
  351. 'parent': '家长',
  352. 'child': '孩子',
  353. 'teacher': '规划师'
  354. }
  355. return roleMap[role] || role
  356. },
  357. goBack: function() {
  358. uni.navigateBack()
  359. },
  360. startQuestionnaire: function() {
  361. uni.navigateTo({
  362. url: '/pages/family/questionnaire?memberId=' + this.memberId
  363. })
  364. },
  365. viewInteractionLog: function() {
  366. uni.navigateTo({
  367. url: '/pages/family/interaction-log?memberId=' + this.memberId
  368. })
  369. }
  370. }
  371. }
  372. </script>
  373. <style>
  374. .relation-detail-container {
  375. min-height: 100vh;
  376. background: #F9FAFB;
  377. padding-bottom: 20px;
  378. }
  379. .custom-nav {
  380. background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
  381. padding: 44px 16px 16px;
  382. display: flex;
  383. align-items: center;
  384. }
  385. .nav-back {
  386. color: #FFFFFF;
  387. font-size: 14px;
  388. margin-right: 16px;
  389. }
  390. .nav-title {
  391. color: #FFFFFF;
  392. font-size: 18px;
  393. font-weight: bold;
  394. }
  395. .loading-state, .error-state {
  396. display: flex;
  397. flex-direction: column;
  398. justify-content: center;
  399. align-items: center;
  400. padding: 80px 20px;
  401. text-align: center;
  402. }
  403. .loading-spinner {
  404. width: 40px;
  405. height: 40px;
  406. border: 4px solid #E5E7EB;
  407. border-top-color: #F97316;
  408. border-radius: 50%;
  409. animation: spin 1s linear infinite;
  410. margin-bottom: 16px;
  411. }
  412. @keyframes spin {
  413. to {
  414. transform: rotate(360deg);
  415. }
  416. }
  417. .retry-btn {
  418. margin-top: 16px;
  419. background: #F97316;
  420. color: #FFFFFF;
  421. border: none;
  422. padding: 10px 32px;
  423. border-radius: 20px;
  424. font-size: 14px;
  425. }
  426. .detail-content {
  427. padding: 16px;
  428. }
  429. .score-card {
  430. background: #FFFFFF;
  431. border-radius: 16px;
  432. padding: 32px 20px;
  433. margin-bottom: 16px;
  434. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  435. text-align: center;
  436. }
  437. .score-circle {
  438. position: relative;
  439. width: 180px;
  440. height: 180px;
  441. margin: 0 auto 16px;
  442. }
  443. .score-canvas {
  444. width: 180px;
  445. height: 180px;
  446. }
  447. .score-content {
  448. position: absolute;
  449. top: 50%;
  450. left: 50%;
  451. transform: translate(-50%, -50%);
  452. }
  453. .score-value {
  454. display: block;
  455. font-size: 48px;
  456. font-weight: bold;
  457. line-height: 1;
  458. }
  459. .score-label {
  460. display: block;
  461. font-size: 12px;
  462. color: #9CA3AF;
  463. margin-top: 8px;
  464. }
  465. .score-level {
  466. margin-top: 12px;
  467. }
  468. .level-text {
  469. font-size: 18px;
  470. font-weight: bold;
  471. color: #1F2937;
  472. }
  473. .score-good {
  474. color: #10B981;
  475. }
  476. .score-medium {
  477. color: #F59E0B;
  478. }
  479. .score-low {
  480. color: #EF4444;
  481. }
  482. .members-card, .dimensions-card, .trend-card, .advice-card {
  483. background: #FFFFFF;
  484. border-radius: 16px;
  485. padding: 20px;
  486. margin-bottom: 16px;
  487. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  488. }
  489. .card-header {
  490. margin-bottom: 16px;
  491. }
  492. .card-title {
  493. font-size: 16px;
  494. font-weight: bold;
  495. color: #1F2937;
  496. }
  497. .members-row {
  498. display: flex;
  499. align-items: center;
  500. justify-content: space-around;
  501. }
  502. .member-item {
  503. text-align: center;
  504. flex: 1;
  505. }
  506. .member-avatar {
  507. width: 56px;
  508. height: 56px;
  509. border-radius: 50%;
  510. display: flex;
  511. justify-content: center;
  512. align-items: center;
  513. margin: 0 auto 8px;
  514. }
  515. .member-avatar-text {
  516. color: #FFFFFF;
  517. font-size: 24px;
  518. font-weight: bold;
  519. }
  520. .member-name {
  521. display: block;
  522. font-size: 14px;
  523. font-weight: 500;
  524. color: #1F2937;
  525. }
  526. .member-role {
  527. display: block;
  528. font-size: 12px;
  529. color: #9CA3AF;
  530. margin-top: 2px;
  531. }
  532. .relation-icon {
  533. font-size: 24px;
  534. color: #D1D5DB;
  535. padding: 0 8px;
  536. }
  537. .dimension-list {
  538. margin-top: 16px;
  539. }
  540. .dimension-item {
  541. display: flex;
  542. align-items: center;
  543. margin-bottom: 16px;
  544. }
  545. .dim-info {
  546. width: 80px;
  547. }
  548. .dim-label {
  549. display: block;
  550. font-size: 14px;
  551. font-weight: 500;
  552. color: #1F2937;
  553. }
  554. .dim-desc {
  555. display: block;
  556. font-size: 12px;
  557. color: #9CA3AF;
  558. margin-top: 2px;
  559. }
  560. .dim-bar {
  561. flex: 1;
  562. height: 8px;
  563. background: #E5E7EB;
  564. border-radius: 4px;
  565. margin: 0 12px;
  566. overflow: hidden;
  567. }
  568. .dim-fill {
  569. height: 100%;
  570. border-radius: 4px;
  571. transition: width 0.3s ease;
  572. }
  573. .dim-value {
  574. width: 32px;
  575. text-align: right;
  576. font-size: 14px;
  577. font-weight: bold;
  578. color: #1F2937;
  579. }
  580. .trend-chart {
  581. height: 150px;
  582. }
  583. .trend-canvas {
  584. width: 100%;
  585. height: 100%;
  586. }
  587. .advice-content {
  588. padding: 12px;
  589. background: #FFF8F0;
  590. border-radius: 8px;
  591. }
  592. .advice-text {
  593. font-size: 14px;
  594. line-height: 1.6;
  595. color: #7C2D12;
  596. }
  597. .action-buttons {
  598. display: flex;
  599. flex-direction: column;
  600. gap: 12px;
  601. padding: 16px;
  602. }
  603. .action-btn {
  604. padding: 14px 24px;
  605. border-radius: 12px;
  606. font-size: 16px;
  607. border: none;
  608. }
  609. .action-btn.primary {
  610. background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
  611. color: #FFFFFF;
  612. }
  613. .action-btn.secondary {
  614. background: #FFFFFF;
  615. color: #F97316;
  616. border: 2px solid #F97316;
  617. }
  618. </style>