index.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971
  1. <template>
  2. <view class="tianpan-container">
  3. <!-- 自定义导航栏 -->
  4. <view class="custom-nav">
  5. <view class="nav-header">
  6. <text class="nav-title">家庭天盘</text>
  7. <picker mode="selector" :range="yearOptions" :value="yearIndex" @change="onYearChange">
  8. <view class="year-picker">
  9. <text class="year-text">{{ currentYear }}</text>
  10. <text class="picker-icon">▼</text>
  11. </view>
  12. </picker>
  13. </view>
  14. <view class="family-name" v-if="familyName">{{ familyName }}</view>
  15. </view>
  16. <!-- 天盘画布 -->
  17. <view class="canvas-wrapper">
  18. <canvas
  19. type="2d"
  20. id="tianpanCanvas"
  21. class="tianpan-canvas"
  22. @touchend="onCanvasTouchEnd"
  23. ></canvas>
  24. </view>
  25. <!-- 标签栏 -->
  26. <view class="tab-bar">
  27. <view
  28. class="tab-item"
  29. :class="{ active: currentTab === 0 }"
  30. @click="switchTab(0)"
  31. >
  32. <text>任务</text>
  33. </view>
  34. <view
  35. class="tab-item"
  36. :class="{ active: currentTab === 1 }"
  37. @click="switchTab(1)"
  38. >
  39. <text>测评</text>
  40. </view>
  41. <view
  42. class="tab-item"
  43. :class="{ active: currentTab === 2 }"
  44. @click="switchTab(2)"
  45. >
  46. <text>预警</text>
  47. </view>
  48. <view
  49. class="tab-item"
  50. :class="{ active: currentTab === 3 }"
  51. @click="switchTab(3)"
  52. >
  53. <text>里程碑</text>
  54. </view>
  55. </view>
  56. <!-- 相关事项列表 -->
  57. <view class="related-items-list">
  58. <view
  59. class="item-card"
  60. v-for="item in currentItems"
  61. :key="item.id"
  62. @click="onItemClick(item)"
  63. >
  64. <view class="item-icon">{{ item.icon || '📋' }}</view>
  65. <view class="item-content">
  66. <text class="item-title">{{ item.title }}</text>
  67. <text class="item-desc">{{ item.description || '' }}</text>
  68. </view>
  69. <view class="item-arrow">›</view>
  70. </view>
  71. <view class="empty-state" v-if="currentItems.length === 0">
  72. <text>暂无相关事项</text>
  73. </view>
  74. </view>
  75. <!-- 每日运势卡片 -->
  76. <view class="fortune-card" @click="goToDailyFortune">
  77. <view class="fortune-header">
  78. <text class="fortune-title">今日运势</text>
  79. <text class="fortune-date">{{ currentDate }}</text>
  80. </view>
  81. <view class="fortune-content">
  82. <text class="fortune-text">{{ dailyFortuneText || '点击查看今日家庭能量运势' }}</text>
  83. </view>
  84. <view class="fortune-arrow">查看详情 ›</view>
  85. </view>
  86. <!-- 成员详情弹窗 -->
  87. <view class="member-popup" v-if="showMemberPopup" @touchmove.stop>
  88. <view class="popup-overlay" @click="closeMemberPopup"></view>
  89. <view class="popup-content">
  90. <view class="popup-header">
  91. <text class="popup-title">{{ selectedMember.nickname || selectedMember.name }}</text>
  92. <text class="popup-close" @click="closeMemberPopup">×</text>
  93. </view>
  94. <view class="popup-body">
  95. <view class="member-info-row">
  96. <text class="info-label">生肖:</text>
  97. <text class="info-value">{{ selectedMember.zodiac || '-' }}</text>
  98. </view>
  99. <view class="member-info-row">
  100. <text class="info-label">星座:</text>
  101. <text class="info-value">{{ selectedMember.westernSign || '-' }}</text>
  102. </view>
  103. <view class="member-info-row">
  104. <text class="info-label">代际:</text>
  105. <text class="info-value">{{ selectedMember.generation || '-' }}</text>
  106. </view>
  107. <view class="energy-bars">
  108. <view class="energy-bar-item" v-for="energy in selectedMember.energy" :key="energy.type">
  109. <text class="energy-label">{{ energy.label }}</text>
  110. <view class="energy-bar">
  111. <view class="energy-fill" :style="{ width: energy.value + '%' }"></view>
  112. </view>
  113. <text class="energy-value">{{ energy.value }}</text>
  114. </view>
  115. </view>
  116. </view>
  117. <view class="popup-footer">
  118. <button class="detail-btn" @click="goToMemberDetail">查看详情</button>
  119. </view>
  120. </view>
  121. </view>
  122. <!-- 加载状态 -->
  123. <view class="loading-overlay" v-if="loading">
  124. <view class="loading-spinner"></view>
  125. <text>加载中...</text>
  126. </view>
  127. </view>
  128. </template>
  129. <script>
  130. import { mapState, mapGetters, mapActions } from 'vuex'
  131. export default {
  132. data: function() {
  133. return {
  134. currentYear: new Date().getFullYear(),
  135. yearOptions: [],
  136. yearIndex: 0,
  137. currentTab: 0,
  138. showMemberPopup: false,
  139. selectedMember: {},
  140. canvasContext: null,
  141. canvasWidth: 375,
  142. canvasHeight: 375,
  143. members: [],
  144. relationships: [],
  145. relatedItems: [],
  146. dailyFortuneText: '',
  147. currentDate: ''
  148. }
  149. },
  150. computed: {
  151. ...mapGetters('tianpan', ['members', 'relationships', 'familyWuxing', 'familyName']),
  152. ...mapState('tianpan', ['loading', 'error']),
  153. currentItems: function() {
  154. var tabMap = ['tasks', 'assessments', 'alerts', 'milestones']
  155. var items = this.relatedItems && this.relatedItems[tabMap[this.currentTab]] ? this.relatedItems[tabMap[this.currentTab]] : []
  156. return items
  157. }
  158. },
  159. onLoad: function() {
  160. this.initYearOptions()
  161. this.loadDashboardData()
  162. this.loadDailyFortune()
  163. this.loadRelatedItems()
  164. },
  165. onReady: function() {
  166. var query = uni.createSelectorQuery()
  167. query.select('#tianpanCanvas')
  168. .fields({ node: true, size: true })
  169. .exec(this.onCanvasReady)
  170. },
  171. methods: {
  172. ...mapActions('tianpan', ['loadDashboard', 'loadDailyFortune', 'loadRelatedItems']),
  173. initYearOptions: function() {
  174. var current = new Date().getFullYear()
  175. for (var i = current - 2; i <= current + 2; i++) {
  176. this.yearOptions.push(i)
  177. if (i === current) {
  178. this.yearIndex = this.yearOptions.length - 1
  179. }
  180. }
  181. this.currentYear = current
  182. },
  183. onYearChange: function(e) {
  184. var index = e.detail.value
  185. var year = this.yearOptions[index]
  186. if (year !== this.currentYear) {
  187. this.currentYear = year
  188. this.loadDashboardData()
  189. }
  190. },
  191. loadDashboardData: function() {
  192. var familyId = uni.getStorageSync('familyId')
  193. if (!familyId) {
  194. return
  195. }
  196. this.loadDashboard({ familyId: familyId, year: this.currentYear, daysAhead: 7 })
  197. .then(this.drawCanvas)
  198. },
  199. onCanvasReady: function(res) {
  200. if (!res || !res[0] || !res[0].node) {
  201. return
  202. }
  203. var ctx = res[0].node.getContext('2d')
  204. var dpr = uni.getSystemInfoSync().pixelRatio
  205. this.canvasContext = ctx
  206. this.canvasContext.scale(dpr, dpr)
  207. this.drawCanvas()
  208. },
  209. drawCanvas: function() {
  210. if (!this.canvasContext) {
  211. return
  212. }
  213. var ctx = this.canvasContext
  214. var w = this.canvasWidth
  215. var h = this.canvasHeight
  216. var cx = w / 2
  217. var cy = h / 2
  218. // 清空画布
  219. ctx.clearRect(0, 0, w, h)
  220. ctx.fillStyle = '#FFF8F0'
  221. ctx.fillRect(0, 0, w, h)
  222. // 获取数据
  223. var members = this.members || []
  224. var relationships = this.relationships || []
  225. // R0: 中心五维能量雷达图
  226. this.drawRadar(ctx, cx, cy, w * 0.11, members)
  227. // R1: 内圈成员节点
  228. this.drawMemberNodes(ctx, cx, cy, w * 0.18, members)
  229. // R2-R3: 外圈相关事项
  230. this.drawRelatedItems(ctx, cx, cy, w * 0.32, w * 0.46, members)
  231. // 绘制关系连线
  232. this.drawRelationshipLines(ctx, cx, cy, relationships)
  233. },
  234. drawRadar: function(ctx, cx, cy, r, members) {
  235. var axes = ['木', '火', '土', '金', '水']
  236. var elementKeys = ['wood', 'fire', 'earth', 'metal', 'water']
  237. var elementColors = ['#10B981', '#FF6B9D', '#FF8C42', '#6366F1', '#F59E0B']
  238. var angleStep = (Math.PI * 2) / 5
  239. // 获取五行数据
  240. var wuxing = this.familyWuxing || {}
  241. var percentages = (wuxing.aggregatePercentages) || {}
  242. var strongest = wuxing.strongestElement || ''
  243. var weakest = wuxing.weakestElement || ''
  244. var familyLabel = this.familyName || '家庭五行'
  245. // 绘制背景五边形
  246. ctx.beginPath()
  247. for (var i = 0; i < 5; i++) {
  248. var angle = i * angleStep - Math.PI / 2
  249. var bx = cx + r * Math.cos(angle)
  250. var by = cy + r * Math.sin(angle)
  251. if (i === 0) {
  252. ctx.moveTo(bx, by)
  253. } else {
  254. ctx.lineTo(bx, by)
  255. }
  256. }
  257. ctx.closePath()
  258. ctx.fillStyle = 'rgba(249, 114, 22, 0.08)'
  259. ctx.fill()
  260. ctx.strokeStyle = 'rgba(249, 114, 22, 0.25)'
  261. ctx.lineWidth = 1
  262. ctx.stroke()
  263. // 绘制彩色轴线 + 轴标签
  264. for (var j = 0; j < 5; j++) {
  265. var a2 = j * angleStep - Math.PI / 2
  266. var lx = cx + r * Math.cos(a2)
  267. var ly = cy + r * Math.sin(a2)
  268. ctx.beginPath()
  269. ctx.moveTo(cx, cy)
  270. ctx.lineTo(lx, ly)
  271. ctx.strokeStyle = elementColors[j] + '44'
  272. ctx.lineWidth = 1
  273. ctx.stroke()
  274. // 轴标签(元素名)
  275. var labelX = cx + (r + 16) * Math.cos(a2)
  276. var labelY = cy + (r + 16) * Math.sin(a2)
  277. ctx.fillStyle = elementColors[j]
  278. ctx.font = 'bold 12px sans-serif'
  279. ctx.textAlign = 'center'
  280. ctx.textBaseline = 'middle'
  281. ctx.fillText(axes[j], labelX, labelY)
  282. }
  283. // 绘制数据驱动多边形(按百分比缩放)
  284. var hasData = false
  285. for (var k = 0; k < elementKeys.length; k++) {
  286. if (percentages[elementKeys[k]] && percentages[elementKeys[k]] > 0) {
  287. hasData = true
  288. break
  289. }
  290. }
  291. if (hasData) {
  292. ctx.beginPath()
  293. for (var m = 0; m < 5; m++) {
  294. var ang = m * angleStep - Math.PI / 2
  295. var pct = (percentages[elementKeys[m]] || 0) / 100
  296. var dataR = r * pct
  297. var dx = cx + dataR * Math.cos(ang)
  298. var dy = cy + dataR * Math.sin(ang)
  299. if (m === 0) {
  300. ctx.moveTo(dx, dy)
  301. } else {
  302. ctx.lineTo(dx, dy)
  303. }
  304. }
  305. ctx.closePath()
  306. ctx.fillStyle = 'rgba(249, 114, 22, 0.2)'
  307. ctx.fill()
  308. ctx.strokeStyle = '#F97316'
  309. ctx.lineWidth = 2
  310. ctx.stroke()
  311. // 数据节点圆点 + 百分比标签
  312. for (var n = 0; n < 5; n++) {
  313. var ang2 = n * angleStep - Math.PI / 2
  314. var pct2 = (percentages[elementKeys[n]] || 0) / 100
  315. var dataR2 = r * pct2
  316. var px = cx + dataR2 * Math.cos(ang2)
  317. var py = cy + dataR2 * Math.sin(ang2)
  318. // 圆点
  319. ctx.beginPath()
  320. ctx.arc(px, py, 3, 0, Math.PI * 2)
  321. ctx.fillStyle = elementColors[n]
  322. ctx.fill()
  323. // 百分比标签(在圆点外侧)
  324. var pctLabelR = dataR2 + (pct2 > 0.3 ? -14 : 14)
  325. var pLabelX = cx + pctLabelR * Math.cos(ang2)
  326. var pLabelY = cy + pctLabelR * Math.sin(ang2)
  327. ctx.fillStyle = elementColors[n]
  328. ctx.font = '10px sans-serif'
  329. ctx.textAlign = 'center'
  330. ctx.textBaseline = 'middle'
  331. ctx.fillText((percentages[elementKeys[n]] || 0) + '%', pLabelX, pLabelY)
  332. }
  333. }
  334. // 中心文字:家庭名 + 最强/最弱
  335. ctx.fillStyle = '#1F2937'
  336. ctx.font = 'bold 11px sans-serif'
  337. ctx.textAlign = 'center'
  338. ctx.textBaseline = 'middle'
  339. ctx.fillText(familyLabel, cx, cy - 6)
  340. if (strongest || weakest) {
  341. ctx.fillStyle = '#6B7280'
  342. ctx.font = '9px sans-serif'
  343. var strongestZh = strongest === 'wood' ? '木' : strongest === 'fire' ? '火' : strongest === 'earth' ? '土' : strongest === 'metal' ? '金' : strongest === 'water' ? '水' : strongest
  344. var weakestZh = weakest === 'wood' ? '木' : weakest === 'fire' ? '火' : weakest === 'earth' ? '土' : weakest === 'metal' ? '金' : weakest === 'water' ? '水' : weakest
  345. ctx.fillText('强:' + strongestZh + ' 弱:' + weakestZh, cx, cy + 7)
  346. }
  347. },
  348. drawMemberNodes: function(ctx, cx, cy, r, members) {
  349. var angleStep = (Math.PI * 2) / (members.length || 1)
  350. for (var i = 0; i < members.length; i++) {
  351. var member = members[i]
  352. var angle = i * angleStep - Math.PI / 2
  353. var x = cx + r * Math.cos(angle)
  354. var y = cy + r * Math.sin(angle)
  355. // 绘制圆形节点
  356. ctx.beginPath()
  357. ctx.arc(x, y, 15, 0, Math.PI * 2)
  358. ctx.fillStyle = this.getMemberColor(member.effectiveRole)
  359. ctx.fill()
  360. ctx.strokeStyle = '#FFF'
  361. ctx.lineWidth = 2
  362. ctx.stroke()
  363. // 绘制名字
  364. ctx.fillStyle = '#FFF'
  365. ctx.font = '10px sans-serif'
  366. ctx.textAlign = 'center'
  367. ctx.textBaseline = 'middle'
  368. ctx.fillText(member.nickname || member.name, x, y)
  369. // 存储点击区域
  370. if (!this.memberHitAreas) {
  371. this.memberHitAreas = []
  372. }
  373. this.memberHitAreas.push({
  374. type: 'member',
  375. data: member,
  376. x: x,
  377. y: y,
  378. r: 15
  379. })
  380. }
  381. },
  382. drawRelatedItems: function(ctx, cx, cy, rInner, rOuter, members) {
  383. var items = this.relatedItems && this.relatedItems.tasks ? this.relatedItems.tasks : []
  384. var angleStep = (Math.PI * 2) / (items.length || 1)
  385. var r = (rInner + rOuter) / 2
  386. for (var i = 0; i < items.length; i++) {
  387. var item = items[i]
  388. var angle = i * angleStep - Math.PI / 2
  389. var x = cx + r * Math.cos(angle)
  390. var y = cy + r * Math.sin(angle)
  391. // 绘制 emoji
  392. ctx.font = '16px sans-serif'
  393. ctx.textAlign = 'center'
  394. ctx.textBaseline = 'middle'
  395. ctx.fillText(item.icon || '📋', x, y)
  396. // 存储点击区域
  397. if (!this.itemHitAreas) {
  398. this.itemHitAreas = []
  399. }
  400. this.itemHitAreas.push({
  401. type: 'item',
  402. data: item,
  403. x: x,
  404. y: y,
  405. r: 12
  406. })
  407. }
  408. },
  409. drawRelationshipLines: function(ctx, cx, cy, relationships) {
  410. for (var i = 0; i < relationships.length; i++) {
  411. var rel = relationships[i]
  412. if (!rel.fromMember || !rel.toMember) {
  413. continue
  414. }
  415. // 计算起点和终点
  416. var memberCount = this.members.length || 1
  417. var angleStep = (Math.PI * 2) / memberCount
  418. var r = this.canvasWidth * 0.18
  419. var fromIndex = -1
  420. var toIndex = -1
  421. for (var j = 0; j < this.members.length; j++) {
  422. if (this.members[j].id === rel.fromMember.id) {
  423. fromIndex = j
  424. }
  425. if (this.members[j].id === rel.toMember.id) {
  426. toIndex = j
  427. }
  428. }
  429. if (fromIndex < 0 || toIndex < 0) {
  430. continue
  431. }
  432. var fromAngle = fromIndex * angleStep - Math.PI / 2
  433. var toAngle = toIndex * angleStep - Math.PI / 2
  434. var x1 = cx + r * Math.cos(fromAngle)
  435. var y1 = cy + r * Math.sin(fromAngle)
  436. var x2 = cx + r * Math.cos(toAngle)
  437. var y2 = cy + r * Math.sin(toAngle)
  438. // 贝塞尔曲线
  439. ctx.beginPath()
  440. ctx.moveTo(x1, y1)
  441. var cx1 = (x1 + x2) / 2 + (y2 - y1) * 0.2
  442. var cy1 = (y1 + y2) / 2 - (x2 - x1) * 0.2
  443. ctx.quadraticCurveTo(cx1, cy1, x2, y2)
  444. // 根据分数设置颜色
  445. var score = rel.score || 0
  446. if (score >= 80) {
  447. ctx.strokeStyle = '#10B981'
  448. } else if (score >= 60) {
  449. ctx.strokeStyle = '#F59E0B'
  450. } else {
  451. ctx.strokeStyle = '#EF4444'
  452. }
  453. ctx.lineWidth = 2
  454. ctx.stroke()
  455. }
  456. },
  457. getMemberColor: function(role) {
  458. var colorMap = {
  459. 'parent': '#F97316',
  460. 'child': '#3B82F6',
  461. 'teacher': '#8B5CF6'
  462. }
  463. return colorMap[role] || '#6B7280'
  464. },
  465. onCanvasTouchEnd: function(e) {
  466. if (!e || !e.changedTouches || !e.changedTouches[0]) {
  467. return
  468. }
  469. var touch = e.changedTouches[0]
  470. var x = touch.x
  471. var y = touch.y
  472. // 检测成员点击
  473. if (this.memberHitAreas) {
  474. for (var i = 0; i < this.memberHitAreas.length; i++) {
  475. var area = this.memberHitAreas[i]
  476. var dx = x - area.x
  477. var dy = y - area.y
  478. if (dx * dx + dy * dy <= area.r * area.r) {
  479. this.showMemberDetail(area.data)
  480. return
  481. }
  482. }
  483. }
  484. // 检测事项点击
  485. if (this.itemHitAreas) {
  486. for (var j = 0; j < this.itemHitAreas.length; j++) {
  487. var itemArea = this.itemHitAreas[j]
  488. var dx2 = x - itemArea.x
  489. var dy2 = y - itemArea.y
  490. if (dx2 * dx2 + dy2 * dy2 <= itemArea.r * itemArea.r) {
  491. this.onItemClick(itemArea.data)
  492. return
  493. }
  494. }
  495. }
  496. },
  497. showMemberDetail: function(member) {
  498. this.selectedMember = member
  499. this.showMemberPopup = true
  500. },
  501. closeMemberPopup: function() {
  502. this.showMemberPopup = false
  503. this.selectedMember = {}
  504. },
  505. goToMemberDetail: function() {
  506. if (!this.selectedMember || !this.selectedMember.id) {
  507. return
  508. }
  509. uni.navigateTo({
  510. url: '/pages/tianpan/member-detail?id=' + this.selectedMember.id
  511. })
  512. this.closeMemberPopup()
  513. },
  514. switchTab: function(index) {
  515. this.currentTab = index
  516. },
  517. onItemClick: function(item) {
  518. if (!item || !item.id) {
  519. return
  520. }
  521. // 根据类型跳转
  522. if (item.type === 'task') {
  523. uni.navigateTo({ url: '/pages/tasks/detail?id=' + item.id })
  524. } else if (item.type === 'assessment') {
  525. uni.navigateTo({ url: '/pages/assessment/detail?id=' + item.id })
  526. } else if (item.type === 'alert') {
  527. uni.navigateTo({ url: '/pages/health/alert-detail?id=' + item.id })
  528. } else if (item.type === 'milestone') {
  529. uni.navigateTo({ url: '/pages/family/milestone-detail?id=' + item.id })
  530. }
  531. },
  532. goToDailyFortune: function() {
  533. uni.navigateTo({
  534. url: '/pages/tianpan/daily-fortune'
  535. })
  536. },
  537. loadDailyFortune: function() {
  538. var familyId = uni.getStorageSync('familyId')
  539. if (!familyId) {
  540. return
  541. }
  542. var today = new Date()
  543. var dateStr = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate()
  544. this.loadDailyFortune({ familyId: familyId, date: dateStr })
  545. .then(this.setFortuneText)
  546. },
  547. setFortuneText: function(data) {
  548. if (data && data.advice) {
  549. this.dailyFortuneText = data.advice
  550. }
  551. var today = new Date()
  552. this.currentDate = today.getFullYear() + '年' + (today.getMonth() + 1) + '月' + today.getDate() + '日'
  553. },
  554. loadRelatedItems: function() {
  555. var familyId = uni.getStorageSync('familyId')
  556. if (!familyId) {
  557. return
  558. }
  559. this.loadRelatedItems({ familyId: familyId })
  560. }
  561. },
  562. watch: {
  563. members: function(newVal) {
  564. if (newVal && this.canvasContext) {
  565. this.drawCanvas()
  566. }
  567. },
  568. relationships: function(newVal) {
  569. if (newVal && this.canvasContext) {
  570. this.drawCanvas()
  571. }
  572. }
  573. }
  574. }
  575. </script>
  576. <style>
  577. .tianpan-container {
  578. min-height: 100vh;
  579. background: linear-gradient(180deg, #FFF8F0 0%, #FFFFFF 100%);
  580. padding-bottom: 20px;
  581. }
  582. /* 自定义导航栏 */
  583. .custom-nav {
  584. background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
  585. padding: 44px 16px 12px;
  586. color: #FFFFFF;
  587. }
  588. .nav-header {
  589. display: flex;
  590. justify-content: space-between;
  591. align-items: center;
  592. }
  593. .nav-title {
  594. font-size: 20px;
  595. font-weight: bold;
  596. }
  597. .year-picker {
  598. display: flex;
  599. align-items: center;
  600. background: rgba(255, 255, 255, 0.2);
  601. padding: 4px 12px;
  602. border-radius: 16px;
  603. }
  604. .year-text {
  605. font-size: 14px;
  606. margin-right: 4px;
  607. }
  608. .picker-icon {
  609. font-size: 10px;
  610. }
  611. .family-name {
  612. text-align: center;
  613. margin-top: 8px;
  614. font-size: 14px;
  615. opacity: 0.9;
  616. }
  617. /* 画布区域 */
  618. .canvas-wrapper {
  619. display: flex;
  620. justify-content: center;
  621. padding: 20px 0;
  622. }
  623. .tianpan-canvas {
  624. width: 375px;
  625. height: 375px;
  626. background: #FFF8F0;
  627. border-radius: 16px;
  628. box-shadow: 0 4px 12px rgba(249, 114, 22, 0.1);
  629. }
  630. /* 标签栏 */
  631. .tab-bar {
  632. display: flex;
  633. justify-content: space-around;
  634. background: #FFFFFF;
  635. margin: 0 16px;
  636. padding: 12px 0;
  637. border-radius: 12px;
  638. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  639. }
  640. .tab-item {
  641. padding: 6px 16px;
  642. border-radius: 20px;
  643. font-size: 14px;
  644. color: #6B7280;
  645. }
  646. .tab-item.active {
  647. background: #F97316;
  648. color: #FFFFFF;
  649. }
  650. /* 相关事项列表 */
  651. .related-items-list {
  652. padding: 16px;
  653. }
  654. .item-card {
  655. display: flex;
  656. align-items: center;
  657. background: #FFFFFF;
  658. padding: 16px;
  659. border-radius: 12px;
  660. margin-bottom: 12px;
  661. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  662. }
  663. .item-icon {
  664. font-size: 28px;
  665. margin-right: 12px;
  666. }
  667. .item-content {
  668. flex: 1;
  669. }
  670. .item-title {
  671. font-size: 16px;
  672. font-weight: 500;
  673. color: #1F2937;
  674. display: block;
  675. margin-bottom: 4px;
  676. }
  677. .item-desc {
  678. font-size: 12px;
  679. color: #9CA3AF;
  680. display: block;
  681. }
  682. .item-arrow {
  683. font-size: 20px;
  684. color: #D1D5DB;
  685. }
  686. .empty-state {
  687. text-align: center;
  688. padding: 40px 0;
  689. color: #9CA3AF;
  690. font-size: 14px;
  691. }
  692. /* 每日运势卡片 */
  693. .fortune-card {
  694. margin: 16px;
  695. padding: 20px;
  696. background: linear-gradient(135deg, #FED7AA 0%, #FDBA74 100%);
  697. border-radius: 16px;
  698. box-shadow: 0 4px 12px rgba(249, 114, 22, 0.2);
  699. }
  700. .fortune-header {
  701. display: flex;
  702. justify-content: space-between;
  703. align-items: center;
  704. margin-bottom: 12px;
  705. }
  706. .fortune-title {
  707. font-size: 18px;
  708. font-weight: bold;
  709. color: #7C2D12;
  710. }
  711. .fortune-date {
  712. font-size: 12px;
  713. color: #9A3412;
  714. }
  715. .fortune-content {
  716. margin-bottom: 12px;
  717. }
  718. .fortune-text {
  719. font-size: 14px;
  720. color: #9A3412;
  721. line-height: 1.6;
  722. }
  723. .fortune-arrow {
  724. text-align: right;
  725. font-size: 12px;
  726. color: #7C2D12;
  727. }
  728. /* 成员弹窗 */
  729. .member-popup {
  730. position: fixed;
  731. top: 0;
  732. left: 0;
  733. right: 0;
  734. bottom: 0;
  735. z-index: 1000;
  736. }
  737. .popup-overlay {
  738. position: absolute;
  739. top: 0;
  740. left: 0;
  741. right: 0;
  742. bottom: 0;
  743. background: rgba(0, 0, 0, 0.5);
  744. }
  745. .popup-content {
  746. position: absolute;
  747. bottom: 0;
  748. left: 0;
  749. right: 0;
  750. background: #FFFFFF;
  751. border-radius: 20px 20px 0 0;
  752. padding: 24px;
  753. max-height: 70vh;
  754. overflow-y: auto;
  755. }
  756. .popup-header {
  757. display: flex;
  758. justify-content: space-between;
  759. align-items: center;
  760. margin-bottom: 20px;
  761. }
  762. .popup-title {
  763. font-size: 20px;
  764. font-weight: bold;
  765. color: #1F2937;
  766. }
  767. .popup-close {
  768. font-size: 28px;
  769. color: #9CA3AF;
  770. line-height: 1;
  771. }
  772. .popup-body {
  773. margin-bottom: 20px;
  774. }
  775. .member-info-row {
  776. display: flex;
  777. margin-bottom: 12px;
  778. }
  779. .info-label {
  780. width: 80px;
  781. color: #6B7280;
  782. font-size: 14px;
  783. }
  784. .info-value {
  785. flex: 1;
  786. color: #1F2937;
  787. font-size: 14px;
  788. }
  789. .energy-bars {
  790. margin-top: 20px;
  791. }
  792. .energy-bar-item {
  793. display: flex;
  794. align-items: center;
  795. margin-bottom: 12px;
  796. }
  797. .energy-label {
  798. width: 40px;
  799. color: #6B7280;
  800. font-size: 14px;
  801. }
  802. .energy-bar {
  803. flex: 1;
  804. height: 8px;
  805. background: #E5E7EB;
  806. border-radius: 4px;
  807. margin: 0 12px;
  808. overflow: hidden;
  809. }
  810. .energy-fill {
  811. height: 100%;
  812. background: linear-gradient(90deg, #F97316 0%, #FB923C 100%);
  813. border-radius: 4px;
  814. }
  815. .energy-value {
  816. width: 30px;
  817. text-align: right;
  818. color: #1F2937;
  819. font-size: 14px;
  820. }
  821. .popup-footer {
  822. display: flex;
  823. justify-content: center;
  824. }
  825. .detail-btn {
  826. background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
  827. color: #FFFFFF;
  828. border: none;
  829. padding: 12px 48px;
  830. border-radius: 24px;
  831. font-size: 16px;
  832. }
  833. /* 加载状态 */
  834. .loading-overlay {
  835. position: fixed;
  836. top: 0;
  837. left: 0;
  838. right: 0;
  839. bottom: 0;
  840. background: rgba(255, 255, 255, 0.9);
  841. display: flex;
  842. flex-direction: column;
  843. justify-content: center;
  844. align-items: center;
  845. z-index: 2000;
  846. }
  847. .loading-spinner {
  848. width: 40px;
  849. height: 40px;
  850. border: 4px solid #E5E7EB;
  851. border-top-color: #F97316;
  852. border-radius: 50%;
  853. animation: spin 1s linear infinite;
  854. margin-bottom: 16px;
  855. }
  856. @keyframes spin {
  857. to {
  858. transform: rotate(360deg);
  859. }
  860. }
  861. </style>