wuxing-sandbox.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. <template>
  2. <view class="wuxing-sandbox">
  3. <!-- header slot -->
  4. <slot name="header">
  5. <view class="sandbox-header">
  6. <text class="sandbox-title">🌟 家庭能量沙盘</text>
  7. <text class="sandbox-badge">{{ displayBadgeText }}</text>
  8. </view>
  9. </slot>
  10. <view class="sandbox-star" :style="{height: starHeight}">
  11. <view class="star-glow-bg"></view>
  12. <!-- Canvas 2D 五行星图 -->
  13. <canvas id="starCanvas" type="2d" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1"></canvas>
  14. <!-- Canvas 点击感应层 -->
  15. <view class="canvas-tap-area" @click="onCanvasTap"></view>
  16. <!-- 顶点:从 sortedDimensions 数据驱动 (心/行/富/智/身) -->
  17. <view class="star-point star-point--top" @click="onPointClick(sortedDimensions[0].code)">
  18. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[0].element)">
  19. <text class="point-icon">{{ sortedDimensions[0].icon }}</text>
  20. </view>
  21. <text class="point-label">{{ sortedDimensions[0].name }}</text>
  22. <text class="point-energy">{{ sortedDimensions[0].energy }}</text>
  23. </view>
  24. <view class="star-point star-point--left" @click="onPointClick(sortedDimensions[1].code)">
  25. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[1].element)">
  26. <text class="point-icon">{{ sortedDimensions[1].icon }}</text>
  27. </view>
  28. <text class="point-label">{{ sortedDimensions[1].name }}</text>
  29. <text class="point-energy">{{ sortedDimensions[1].energy }}</text>
  30. </view>
  31. <view class="star-point star-point--right" @click="onPointClick(sortedDimensions[2].code)">
  32. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[2].element)">
  33. <text class="point-icon">{{ sortedDimensions[2].icon }}</text>
  34. </view>
  35. <text class="point-label">{{ sortedDimensions[2].name }}</text>
  36. <text class="point-energy">{{ sortedDimensions[2].energy }}</text>
  37. </view>
  38. <view class="star-point star-point--ll" @click="onPointClick(sortedDimensions[3].code)">
  39. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[3].element)">
  40. <text class="point-icon">{{ sortedDimensions[3].icon }}</text>
  41. </view>
  42. <text class="point-label">{{ sortedDimensions[3].name }}</text>
  43. <text class="point-energy">{{ sortedDimensions[3].energy }}</text>
  44. </view>
  45. <view class="star-point star-point--lr" @click="onPointClick(sortedDimensions[4].code)">
  46. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[4].element)">
  47. <text class="point-icon">{{ sortedDimensions[4].icon }}</text>
  48. </view>
  49. <text class="point-label">{{ sortedDimensions[4].name }}</text>
  50. <text class="point-energy">{{ sortedDimensions[4].energy }}</text>
  51. </view>
  52. <!-- 中心点击区域(太极由 Canvas 绘制) -->
  53. <view class="star-center" @click="onCenterClick">
  54. <text class="energy-hint">{{ viewMode === 'family' ? '家庭能量' : (currentMember ? currentMember.name : '个人') }}</text>
  55. </view>
  56. </view>
  57. <!-- dimension-bar slot -->
  58. <slot name="dimension-bar" v-if="showDimensionBar && mode !== 'preview'">
  59. <view class="dimension-bar">
  60. <view class="dim-item dim-item--wood">🌿身 {{ activeScores[0] }}%</view>
  61. <view class="dim-item dim-item--fire">🔥心 {{ activeScores[1] }}%</view>
  62. <view class="dim-item dim-item--earth">🌏智 {{ activeScores[2] }}%</view>
  63. <view class="dim-item dim-item--metal">⚡行 {{ activeScores[3] }}%</view>
  64. <view class="dim-item dim-item--water">💧富 {{ activeScores[4] }}%</view>
  65. </view>
  66. </slot>
  67. </view>
  68. </template>
  69. <script>
  70. import { renderStar, calcStarVertices } from './wuxing-sandbox-helpers/render.js'
  71. import { FillAnimator } from './wuxing-sandbox-helpers/animation.js'
  72. import { detectHitArm, armIndexToDomain } from './wuxing-sandbox-helpers/hitdetect.js'
  73. var DIM_KEYS = ['body', 'mind', 'wisdom', 'action', 'wealth']
  74. export default {
  75. props: {
  76. /** 模式:'preview' 随机数据 / 'live' 真实数据 */
  77. mode: {
  78. type: String,
  79. default: 'preview',
  80. validator: function(v) { return v === 'preview' || v === 'live' }
  81. },
  82. /** 五维分值对象 {body,mind,wisdom,action,wealth},0-100 */
  83. scores: {
  84. type: Object,
  85. default: function() { return { body: 0, mind: 0, wisdom: 0, action: 0, wealth: 0 } }
  86. },
  87. /** 综合分值 0-100 */
  88. overallScore: {
  89. type: Number,
  90. default: 0
  91. },
  92. /** 家庭成员数组(MemberEnergyDTO 格式) */
  93. members: {
  94. type: Array,
  95. default: function() { return [] }
  96. },
  97. /** 顶部徽章文字,空则自动计算 */
  98. badgeText: {
  99. type: String,
  100. default: ''
  101. },
  102. /** 是否显示底部维度条 */
  103. showDimensionBar: {
  104. type: Boolean,
  105. default: true
  106. },
  107. /** 五维能量数据(账本模式) */
  108. dimensions: {
  109. type: Array,
  110. default: function() { return [] }
  111. },
  112. totalEnergy: {
  113. type: Number,
  114. default: 0
  115. },
  116. totalHealthIndex: {
  117. type: Number,
  118. default: 0
  119. },
  120. /** 星图区域高度 */
  121. starHeight: {
  122. type: String,
  123. default: '480rpx'
  124. }
  125. },
  126. data: function() {
  127. return {
  128. viewMode: 'family',
  129. selectedMemberIndex: 0,
  130. previewScores: [0, 0, 0, 0, 0],
  131. canvasReady: false,
  132. canvasWidth: 0,
  133. canvasHeight: 0,
  134. ctx: null,
  135. dpr: 1,
  136. _canvasLeft: 0,
  137. _canvasTop: 0,
  138. animBody: 0,
  139. animMind: 0,
  140. animWisdom: 0,
  141. animAction: 0,
  142. animWealth: 0,
  143. _animator: null
  144. }
  145. },
  146. computed: {
  147. isPreview: function() {
  148. return this.mode === 'preview'
  149. },
  150. currentMember: function() {
  151. if (this.isPreview) return null
  152. var m = this.members
  153. if (!m || m.length === 0) return null
  154. if (this.selectedMemberIndex >= m.length) return m[0]
  155. return m[this.selectedMemberIndex]
  156. },
  157. /** 当前活跃的五维值数组 [body, mind, wisdom, action, wealth] */
  158. activeScores: function() {
  159. if (this.isPreview) return this.previewScores
  160. if (this.viewMode === 'family') {
  161. var s = this.scores
  162. return [
  163. s.body || 0,
  164. s.mind || 0,
  165. s.wisdom || 0,
  166. s.action || 0,
  167. s.wealth || 0
  168. ]
  169. }
  170. var m = this.currentMember
  171. if (!m) return [0, 0, 0, 0, 0]
  172. return [
  173. m.bodyScore || 0,
  174. m.mindScore || 0,
  175. m.wisdomScore || 0,
  176. m.actionScore || 0,
  177. m.wealthScore || 0
  178. ]
  179. },
  180. activeOverall: function() {
  181. if (this.isPreview) {
  182. var s = this.previewScores
  183. return Math.round((s[0] + s[1] + s[2] + s[3] + s[4]) / 5)
  184. }
  185. if (this.viewMode === 'family') return this.overallScore || 0
  186. var m = this.currentMember
  187. return m ? (m.overallScore || 0) : 0
  188. },
  189. activeTs: function() {
  190. return [
  191. this.activeScores[0] / 100,
  192. this.activeScores[1] / 100,
  193. this.activeScores[2] / 100,
  194. this.activeScores[3] / 100,
  195. this.activeScores[4] / 100
  196. ]
  197. },
  198. displayBadgeText: function() {
  199. if (this.badgeText) return this.badgeText
  200. var label = this.viewMode === 'family' ? '家庭能量' : (this.currentMember ? this.currentMember.name : '个人能量')
  201. return label + ' ' + (this.activeOverall || 0) + '%'
  202. },
  203. _allDimTs: function() {
  204. return this.activeTs
  205. },
  206. hasMultipleMembers: function() {
  207. return this.members && this.members.length > 1
  208. },
  209. /** 按五行布局排序的维度数据(心·火(顶)/行·木(左)/富·水(右)/智·金(左下)/身·土(右下)) */
  210. sortedDimensions: function() {
  211. var order = ['mind', 'action', 'wealth', 'wisdom', 'body']
  212. var dimMap = {}
  213. for (var i = 0; i < this.dimensions.length; i++) {
  214. var d = this.dimensions[i]
  215. if (d && d.code) dimMap[d.code] = d
  216. }
  217. var result = []
  218. for (var j = 0; j < order.length; j++) {
  219. var code = order[j]
  220. result.push(dimMap[code] || { code: code, name: '', icon: '', element: '', energy: 0, healthIndex: 0 })
  221. }
  222. return result
  223. }
  224. },
  225. watch: {
  226. _allDimTs: function() {
  227. if (this.canvasReady) {
  228. this._startAnimation()
  229. }
  230. }
  231. },
  232. mounted: function() {
  233. var self = this
  234. // 预览模式:生成随机五维值
  235. if (this.isPreview) {
  236. var scores = []
  237. for (var i = 0; i < 5; i++) {
  238. scores.push(Math.floor(Math.random() * 51) + 35)
  239. }
  240. this.previewScores = scores
  241. }
  242. // 等待 DOM 就绪后初始化 Canvas
  243. setTimeout(function() {
  244. self.initCanvas()
  245. }, 100)
  246. },
  247. beforeDestroy: function() {
  248. if (this._animator) {
  249. this._animator.cancel()
  250. this._animator = null
  251. }
  252. },
  253. methods: {
  254. /* ===========================
  255. 视图切换
  256. =========================== */
  257. onCenterClick: function() {
  258. // 预览模式不切换
  259. if (this.isPreview) {
  260. this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  261. return
  262. }
  263. var members = this.members
  264. if (!members || members.length === 0) {
  265. this.viewMode = 'family'
  266. this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  267. return
  268. }
  269. if (this.viewMode === 'family') {
  270. this.viewMode = 'member'
  271. } else {
  272. if (members.length === 1) {
  273. this.viewMode = 'family'
  274. this.selectedMemberIndex = 0
  275. } else {
  276. var nextIdx = this.selectedMemberIndex + 1
  277. if (nextIdx >= members.length) {
  278. this.viewMode = 'family'
  279. this.selectedMemberIndex = 0
  280. } else {
  281. this.selectedMemberIndex = nextIdx
  282. }
  283. }
  284. }
  285. this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  286. this.$emit('mode-change', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  287. },
  288. onPointClick: function(code) {
  289. this.$emit('point-click', {
  290. code: code,
  291. viewMode: this.viewMode,
  292. member: this.currentMember
  293. })
  294. },
  295. /** 五行元素 → CSS 颜色类名 */
  296. dimElementColor: function(element) {
  297. var map = { '火': 'fire', '水': 'water', '土': 'earth', '金': 'metal', '木': 'wood' }
  298. return map[element] || 'fire'
  299. },
  300. /* ===========================
  301. Canvas 初始化 & 渲染
  302. =========================== */
  303. initCanvas: function() {
  304. var self = this
  305. uni.createSelectorQuery().in(this).select('#starCanvas')
  306. .fields({ node: true, size: true, rect: true })
  307. .exec(function(res) {
  308. if (!res || !res[0] || !res[0].node) return
  309. var canvas = res[0].node
  310. var width = res[0].width
  311. var height = res[0].height
  312. if (!width || !height) return
  313. var pixelRatio = uni.getSystemInfoSync().pixelRatio || 2
  314. canvas.width = width * pixelRatio
  315. canvas.height = height * pixelRatio
  316. var ctx = canvas.getContext('2d')
  317. ctx.scale(pixelRatio, pixelRatio)
  318. self.ctx = ctx
  319. self.canvasWidth = width
  320. self.canvasHeight = height
  321. self.dpr = pixelRatio
  322. self._canvasLeft = res[0].left || 0
  323. self._canvasTop = res[0].top || 0
  324. self.canvasReady = true
  325. self._startAnimation()
  326. })
  327. },
  328. _doRender: function() {
  329. if (!this.ctx) return
  330. var ts = [this.animBody, this.animMind, this.animWisdom, this.animAction, this.animWealth]
  331. renderStar(this.ctx, this.canvasWidth, this.canvasHeight, ts)
  332. },
  333. /* ===========================
  334. 动画控制
  335. =========================== */
  336. _startAnimation: function() {
  337. var self = this
  338. if (!this._animator) {
  339. this._animator = new FillAnimator(
  340. function() {
  341. return [self.animBody, self.animMind, self.animWisdom, self.animAction, self.animWealth]
  342. },
  343. function(interpolated) {
  344. self.animBody = interpolated[0]
  345. self.animMind = interpolated[1]
  346. self.animWisdom = interpolated[2]
  347. self.animAction = interpolated[3]
  348. self.animWealth = interpolated[4]
  349. self._doRender()
  350. }
  351. )
  352. }
  353. this._animator.start(this.activeTs, 600)
  354. },
  355. /* ===========================
  356. 点击检测
  357. =========================== */
  358. onCanvasTap: function(e) {
  359. if (!this.canvasReady) return
  360. var pageX = e.detail.x
  361. var pageY = e.detail.y
  362. if (typeof pageX !== 'number') {
  363. var touch = e.touches ? e.touches[0] : null
  364. if (touch) {
  365. pageX = touch.x || touch.clientX || 0
  366. pageY = touch.y || touch.clientY || 0
  367. } else {
  368. var changedTouch = e.changedTouches ? e.changedTouches[0] : null
  369. if (changedTouch) {
  370. pageX = changedTouch.x || changedTouch.clientX || 0
  371. pageY = changedTouch.y || changedTouch.clientY || 0
  372. } else {
  373. return
  374. }
  375. }
  376. }
  377. var relX = pageX - (this._canvasLeft || 0)
  378. var relY = pageY - (this._canvasTop || 0)
  379. if (relX < 0 || relY < 0 || relX > this.canvasWidth || relY > this.canvasHeight) return
  380. var cx = this.canvasWidth / 2
  381. var cy = this.canvasHeight / 2
  382. var outerR = Math.min(this.canvasWidth, this.canvasHeight) * 0.42
  383. var armIdx = detectHitArm(relX, relY, cx, cy, outerR, calcStarVertices)
  384. if (armIdx >= 0) {
  385. this.onPointClick(armIndexToDomain(armIdx))
  386. }
  387. }
  388. }
  389. }
  390. </script>
  391. <style scoped>
  392. /* ============================================================
  393. 容器
  394. ============================================================ */
  395. .wuxing-sandbox {
  396. margin: 20rpx 30rpx;
  397. padding: 20rpx 24rpx 24rpx;
  398. background: linear-gradient(135deg, #1A2A4A, #1E3A5F, #16213E);
  399. border-radius: 24rpx;
  400. position: relative;
  401. overflow: hidden;
  402. }
  403. .wuxing-sandbox::before {
  404. content: '';
  405. position: absolute;
  406. top: -50%;
  407. left: -50%;
  408. width: 200%;
  409. height: 200%;
  410. background: radial-gradient(ellipse at 30% 20%, rgba(91, 155, 213, 0.04), transparent 60%),
  411. radial-gradient(ellipse at 70% 80%, rgba(66, 165, 245, 0.03), transparent 60%);
  412. pointer-events: none;
  413. }
  414. /* ---- 标题 ---- */
  415. .sandbox-header {
  416. display: flex;
  417. justify-content: space-between;
  418. align-items: center;
  419. margin-bottom: 12rpx;
  420. position: relative;
  421. z-index: 5;
  422. }
  423. .sandbox-title { font-size: 26rpx; font-weight: bold; color: #8FC5E8; }
  424. .sandbox-badge {
  425. font-size: 20rpx;
  426. color: rgba(143,197,232,0.7);
  427. background: rgba(143,197,232,0.1);
  428. padding: 4rpx 14rpx;
  429. border-radius: 20rpx;
  430. }
  431. /* ---- 五角星区域 ---- */
  432. .sandbox-star {
  433. position: relative;
  434. width: 100%;
  435. display: flex;
  436. justify-content: center;
  437. align-items: center;
  438. }
  439. /* 背景光晕 */
  440. .star-glow-bg {
  441. position: absolute; width: 440rpx; height: 440rpx;
  442. top: 50%; left: 50%; transform: translate(-50%, -50%);
  443. background: radial-gradient(circle, rgba(91,155,213,0.10), transparent 70%);
  444. border-radius: 50%; pointer-events: none;
  445. }
  446. /* Canvas 点击感应层 */
  447. .canvas-tap-area {
  448. position: absolute;
  449. top: 0; left: 0; right: 0; bottom: 0;
  450. z-index: 2;
  451. }
  452. /* ============================================================
  453. 顶点:图标 + 中文标签
  454. ============================================================ */
  455. .star-point {
  456. position: absolute; transform: translate(-50%, -50%); z-index: 3; cursor: pointer;
  457. display: flex; flex-direction: column; align-items: center;
  458. }
  459. .star-point--top { top: 3%; left: 50%; }
  460. .star-point--left { top: 38%; left: 5%; }
  461. .star-point--right { top: 38%; left: 95%; }
  462. .star-point--lr { top: 73%; left: 75%; }
  463. .star-point--ll { top: 73%; left: 25%; }
  464. .point-dot {
  465. width: 52rpx; height: 52rpx; border-radius: 50%;
  466. display: flex; align-items: center; justify-content: center;
  467. box-shadow: 0 0 24rpx rgba(255,255,255,0.25);
  468. }
  469. .point-dot--fire { background: linear-gradient(135deg, #FF6B35, #E65100); }
  470. .point-dot--water { background: linear-gradient(135deg, #42A5F5, #1565C0); }
  471. .point-dot--earth { background: linear-gradient(135deg, #8D6E63, #5D4037); }
  472. .point-dot--metal { background: linear-gradient(135deg, #FFD700, #FF8F00); }
  473. .point-dot--wood { background: linear-gradient(135deg, #4CAF50, #2E7D32); }
  474. .point-icon { font-size: 26rpx; line-height: 1; }
  475. .point-label {
  476. font-size: 22rpx; font-weight: bold; color: rgba(255,255,255,0.9);
  477. margin-top: 6rpx; text-shadow: 0 1rpx 4rpx rgba(0,0,0,0.5);
  478. letter-spacing: 2rpx;
  479. }
  480. .point-energy {
  481. font-size: 18rpx; color: rgba(143,197,232,0.8);
  482. background: rgba(143,197,232,0.12);
  483. padding: 1rpx 10rpx; border-radius: 12rpx;
  484. margin-top: 2rpx;
  485. }
  486. /* ============================================================
  487. 中心点击区域
  488. ============================================================ */
  489. .star-center {
  490. position: absolute; top: 50%; left: 50%;
  491. transform: translate(-50%, -50%); z-index: 4;
  492. width: 130rpx; height: 130rpx; border-radius: 50%;
  493. cursor: pointer;
  494. }
  495. .energy-hint {
  496. position: absolute; bottom: -30rpx; left: 50%;
  497. transform: translateX(-50%);
  498. font-size: 18rpx; color: rgba(143,197,232,0.4);
  499. white-space: nowrap;
  500. }
  501. /* ============================================================
  502. 维度详情条
  503. ============================================================ */
  504. .dimension-bar {
  505. display: flex; align-items: center; justify-content: center;
  506. margin-top: 16rpx; gap: 8rpx;
  507. position: relative; z-index: 5; flex-wrap: wrap;
  508. }
  509. .dim-item {
  510. font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 10rpx; white-space: nowrap;
  511. }
  512. .dim-item--wood { color: #4CAF50; background: rgba(76,175,80,0.12); }
  513. .dim-item--fire { color: #FF6B35; background: rgba(255,107,53,0.12); }
  514. .dim-item--earth { color: #8D6E63; background: rgba(141,110,99,0.12); }
  515. .dim-item--metal { color: #FFD700; background: rgba(255,215,0,0.12); }
  516. .dim-item--water { color: #42A5F5; background: rgba(66,165,245,0.12); }
  517. </style>