wuxing-sandbox.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. <template>
  2. <view class="wuxing-sandbox">
  3. <!-- header slot -->
  4. <slot name="header">
  5. <view class="sandbox-header">
  6. <image class="sandbox-logo" src="/static/logo.png" mode="aspectFit"></image>
  7. <text class="sandbox-title">浠艾福能量沙盘</text>
  8. <text class="sandbox-badge">{{ displayBadgeText }}</text>
  9. </view>
  10. </slot>
  11. <!-- 个人/全家切换开关(仅 familyUnlocked 时显示) -->
  12. <view class="sandbox-toggle" v-if="familyUnlocked && hasMultipleMembers && mode !== 'preview'">
  13. <view class="toggle-item" :class="activeClassName" @click="onToggle('personal')">
  14. <text class="toggle-icon">👤</text>
  15. <text class="toggle-text">个人</text>
  16. </view>
  17. <view class="toggle-item toggle-item--family" :class="activeFamilyClassName" @click="onToggle('family')">
  18. <text class="toggle-icon">👨‍👩‍👧‍👦</text>
  19. <text class="toggle-text">全家</text>
  20. </view>
  21. </view>
  22. <view class="sandbox-star" :style="{height: starHeight}">
  23. <view class="star-glow-bg"></view>
  24. <!-- Canvas 2D 五行星图 -->
  25. <canvas id="starCanvas" type="2d" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1"></canvas>
  26. <!-- Canvas 粒子动画覆盖层 -->
  27. <canvas id="flowCanvas" type="2d" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1;pointer-events:none"></canvas>
  28. <!-- Canvas 点击感应层 -->
  29. <view class="canvas-tap-area" @click="onCanvasTap"></view>
  30. <!-- 顶点:从 sortedDimensions 顺时针排 [心, 身, 智, 富, 行] -->
  31. <view class="star-point star-point--top star-point--row" @click="onPointClick(sortedDimensions[0].code)">
  32. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[0].code)">
  33. <text class="point-icon">{{ sortedDimensions[0].icon }}</text>
  34. </view>
  35. <text class="point-label">{{ sortedDimensions[0].name }}</text>
  36. <text class="point-energy">{{ sortedDimensions[0].energy }}</text>
  37. </view>
  38. <view class="star-point star-point--right" @click="onPointClick(sortedDimensions[1].code)">
  39. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[1].code)">
  40. <text class="point-icon">{{ sortedDimensions[1].icon }}</text>
  41. </view>
  42. <text class="point-label">{{ sortedDimensions[1].name }}</text>
  43. <text class="point-energy">{{ sortedDimensions[1].energy }}</text>
  44. </view>
  45. <view class="star-point star-point--lr star-point--row" @click="onPointClick(sortedDimensions[2].code)">
  46. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[2].code)">
  47. <text class="point-icon">{{ sortedDimensions[2].icon }}</text>
  48. </view>
  49. <text class="point-label">{{ sortedDimensions[2].name }}</text>
  50. <text class="point-energy">{{ sortedDimensions[2].energy }}</text>
  51. </view>
  52. <view class="star-point star-point--ll star-point--rev" @click="onPointClick(sortedDimensions[3].code)">
  53. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[3].code)">
  54. <text class="point-icon">{{ sortedDimensions[3].icon }}</text>
  55. </view>
  56. <text class="point-label">{{ sortedDimensions[3].name }}</text>
  57. <text class="point-energy">{{ sortedDimensions[3].energy }}</text>
  58. </view>
  59. <view class="star-point star-point--left" @click="onPointClick(sortedDimensions[4].code)">
  60. <view class="point-dot" :class="'point-dot--' + dimElementColor(sortedDimensions[4].code)">
  61. <text class="point-icon">{{ sortedDimensions[4].icon }}</text>
  62. </view>
  63. <text class="point-label">{{ sortedDimensions[4].name }}</text>
  64. <text class="point-energy">{{ sortedDimensions[4].energy }}</text>
  65. </view>
  66. <!-- 中心点击区域(太极由 Canvas 绘制) -->
  67. <view class="star-center" @click="onCenterClick"></view>
  68. </view>
  69. <!-- dimension-bar slot -->
  70. <slot name="dimension-bar" v-if="showDimensionBar && mode !== 'preview'">
  71. <view class="dimension-bar">
  72. <view class="dim-item dim-item--earth">🌏身 {{ activeScores[0] }}%</view>
  73. <view class="dim-item dim-item--fire">🔥心 {{ activeScores[1] }}%</view>
  74. <view class="dim-item dim-item--metal">⚡智 {{ activeScores[2] }}%</view>
  75. <view class="dim-item dim-item--wood">🌿行 {{ activeScores[3] }}%</view>
  76. <view class="dim-item dim-item--water">💧富 {{ activeScores[4] }}%</view>
  77. </view>
  78. </slot>
  79. </view>
  80. </template>
  81. <script>
  82. import { renderStar, calcStarVertices } from './wuxing-sandbox-helpers/render.js'
  83. import { FillAnimator } from './wuxing-sandbox-helpers/animation.js'
  84. import { detectHitArm, armIndexToDomain } from './wuxing-sandbox-helpers/hitdetect.js'
  85. import { FlowAnimation } from './wuxing-sandbox-helpers/flow-animation.js'
  86. var DIM_KEYS = ['body', 'mind', 'wisdom', 'action', 'wealth']
  87. /** 维度 → 五行映射(API 缺字段时的兜底) */
  88. var DIM_META = {
  89. mind: { name: '心', icon: '🔥', element: '火', color: 'fire' },
  90. action: { name: '行', icon: '🌿', element: '木', color: 'wood' },
  91. wealth: { name: '富', icon: '💧', element: '水', color: 'water' },
  92. wisdom: { name: '智', icon: '⚡', element: '金', color: 'metal' },
  93. body: { name: '身', icon: '🌏', element: '土', color: 'earth' }
  94. }
  95. export default {
  96. props: {
  97. /** 模式:'preview' 随机数据 / 'live' 真实数据 */
  98. mode: {
  99. type: String,
  100. default: 'preview',
  101. validator: function(v) { return v === 'preview' || v === 'live' }
  102. },
  103. /** 五维分值对象 {body,mind,wisdom,action,wealth},0-100 */
  104. scores: {
  105. type: Object,
  106. default: function() { return { body: 0, mind: 0, wisdom: 0, action: 0, wealth: 0 } }
  107. },
  108. /** 综合分值 0-100 */
  109. overallScore: {
  110. type: Number,
  111. default: 0
  112. },
  113. /** 家庭成员数组(MemberEnergyDTO 格式) */
  114. members: {
  115. type: Array,
  116. default: function() { return [] }
  117. },
  118. /** "我的"个人数据(参数指定成员的 EnergySandboxDTO,含 bodyScore/mindScore/.../overallScore) */
  119. personalData: {
  120. type: Object,
  121. default: null
  122. },
  123. /** 顶部徽章文字,空则自动计算 */
  124. badgeText: {
  125. type: String,
  126. default: ''
  127. },
  128. /** 是否显示底部维度条 */
  129. showDimensionBar: {
  130. type: Boolean,
  131. default: true
  132. },
  133. /** 五维能量数据(账本模式) */
  134. dimensions: {
  135. type: Array,
  136. default: function() { return [] }
  137. },
  138. totalEnergy: {
  139. type: Number,
  140. default: 0
  141. },
  142. totalHealthIndex: {
  143. type: Number,
  144. default: 0
  145. },
  146. /** 星图区域高度 */
  147. starHeight: {
  148. type: String,
  149. default: '540rpx'
  150. },
  151. /** 是否启用全家沙盘切换(控制个人/全家开关显隐) */
  152. familyUnlocked: {
  153. type: Boolean,
  154. default: false
  155. },
  156. /** 当前视角模式(由父组件控制) */
  157. currentView: {
  158. type: String,
  159. default: 'personal',
  160. validator: function(v) { return v === 'personal' || v === 'family' }
  161. }
  162. },
  163. data: function() {
  164. return {
  165. viewMode: 'personal',
  166. selectedMemberIndex: 0,
  167. previewScores: [0, 0, 0, 0, 0],
  168. canvasReady: false,
  169. canvasWidth: 0,
  170. canvasHeight: 0,
  171. ctx: null,
  172. dpr: 1,
  173. _canvasLeft: 0,
  174. _canvasTop: 0,
  175. animBody: 0,
  176. animMind: 0,
  177. animWisdom: 0,
  178. animAction: 0,
  179. animWealth: 0,
  180. _animator: null,
  181. _flowAnim: null,
  182. _flowCanvasReady: false
  183. }
  184. },
  185. computed: {
  186. isPreview: function() {
  187. return this.mode === 'preview'
  188. },
  189. currentMember: function() {
  190. if (this.isPreview) return null
  191. var m = this.members
  192. if (!m || m.length === 0) return null
  193. if (this.selectedMemberIndex >= m.length) return m[0]
  194. return m[this.selectedMemberIndex]
  195. },
  196. /** 当前活跃的五维值数组 [body, mind, wisdom, action, wealth] */
  197. activeScores: function() {
  198. if (this.isPreview) return this.previewScores
  199. if (this.viewMode === 'family') {
  200. var s = this.scores
  201. return [
  202. s.body || 0,
  203. s.mind || 0,
  204. s.wisdom || 0,
  205. s.action || 0,
  206. s.wealth || 0
  207. ]
  208. }
  209. var p = this.personalData
  210. if (p) {
  211. return [
  212. p.bodyScore || 0,
  213. p.mindScore || 0,
  214. p.wisdomScore || 0,
  215. p.actionScore || 0,
  216. p.wealthScore || 0
  217. ]
  218. }
  219. var m = this.currentMember
  220. if (!m) return [0, 0, 0, 0, 0]
  221. return [
  222. m.bodyScore || 0,
  223. m.mindScore || 0,
  224. m.wisdomScore || 0,
  225. m.actionScore || 0,
  226. m.wealthScore || 0
  227. ]
  228. },
  229. activeOverall: function() {
  230. if (this.isPreview) {
  231. var s = this.previewScores
  232. return Math.round((s[0] + s[1] + s[2] + s[3] + s[4]) / 5)
  233. }
  234. if (this.viewMode === 'family') return this.overallScore || 0
  235. var p = this.personalData
  236. if (p && p.overallScore != null) return p.overallScore
  237. var m = this.currentMember
  238. return m ? (m.overallScore || 0) : 0
  239. },
  240. activeTs: function() {
  241. return [
  242. this.activeScores[0] / 100,
  243. this.activeScores[1] / 100,
  244. this.activeScores[2] / 100,
  245. this.activeScores[3] / 100,
  246. this.activeScores[4] / 100
  247. ]
  248. },
  249. displayBadgeText: function() {
  250. if (this.badgeText) return this.badgeText
  251. var label = this.viewMode === 'family' ? '家庭能量' : (this.currentMember ? this.currentMember.name : '个人能量')
  252. return label + ' ' + (this.activeOverall || 0) + '%'
  253. },
  254. _allDimTs: function() {
  255. return this.activeTs
  256. },
  257. hasMultipleMembers: function() {
  258. return this.members && this.members.length > 1
  259. },
  260. activeClassName: function() {
  261. return this.currentView === 'personal' ? 'toggle-item--active' : ''
  262. },
  263. activeFamilyClassName: function() {
  264. return this.currentView === 'family' ? 'toggle-item--active' : ''
  265. },
  266. /** 家庭能量值(用于太极阳面) */
  267. familyEnergy: function() {
  268. if (this.isPreview) {
  269. var s = this.previewScores
  270. return Math.round((s[0] + s[1] + s[2] + s[3] + s[4]) / 5)
  271. }
  272. return this.viewMode === 'family' ? (this.overallScore || 0) : (this.overallScore || 0)
  273. },
  274. /** 个人能量值(用于太极阴面) */
  275. personalEnergy: function() {
  276. if (this.isPreview) return 0
  277. if (this.viewMode === 'member') {
  278. var m = this.currentMember
  279. return m ? (m.overallScore || 0) : 0
  280. }
  281. // 家庭模式下取当前选中成员
  282. var m = this.currentMember
  283. return m ? (m.overallScore || 0) : 0
  284. },
  285. /** 按五行顺序绕圈排列
  286. * 固定序:心(火)→身(土)→智(金)→富(水)→行(木),心始终在顶部
  287. * 其余按五行相生序顺时针依次排:左边→右边→左下→右下
  288. */
  289. sortedDimensions: function() {
  290. var order = ['mind', 'body', 'wisdom', 'wealth', 'action']
  291. var dimMap = {}
  292. for (var i = 0; i < this.dimensions.length; i++) {
  293. var d = this.dimensions[i]
  294. if (d && d.code) dimMap[d.code] = d
  295. }
  296. var all = []
  297. for (var j = 0; j < order.length; j++) {
  298. var code = order[j]
  299. var d = dimMap[code]
  300. var meta = DIM_META[code] || {}
  301. if (d) {
  302. if (!d.name) d.name = meta.name || ''
  303. if (!d.icon) d.icon = meta.icon || ''
  304. if (!d.element) d.element = meta.element || ''
  305. all.push(d)
  306. } else {
  307. all.push({ code: code, name: meta.name || '', icon: meta.icon || '', element: meta.element || '', energy: 0, healthIndex: 0 })
  308. }
  309. }
  310. return all
  311. // all = [心→顶, 身→右, 智→右下, 富→左下, 行→左]
  312. }
  313. },
  314. watch: {
  315. _allDimTs: function() {
  316. if (this.canvasReady) {
  317. this._startAnimation()
  318. }
  319. },
  320. sortedDimensions: function(newVal) {
  321. if (this._flowAnim && this._flowCanvasReady) {
  322. var dimOrder = newVal.map(function(d) { return d.code })
  323. this._flowAnim.refreshPaths({ dimOrder: dimOrder })
  324. }
  325. },
  326. currentView: function(newVal) {
  327. if (newVal && newVal !== this.viewMode) {
  328. this.viewMode = newVal
  329. }
  330. },
  331. hasMultipleMembers: function(newVal) {
  332. if (!newVal && this.viewMode === 'family') {
  333. this.viewMode = 'personal'
  334. }
  335. }
  336. },
  337. mounted: function() {
  338. var self = this
  339. // 预览模式:生成随机五维值(缓存以避免每次 mount 重算)
  340. if (this.isPreview) {
  341. var cacheKey = '_wuxing_preview_scores'
  342. var cached = uni.getStorageSync(cacheKey)
  343. if (cached && Array.isArray(cached) && cached.length === 5) {
  344. this.previewScores = cached
  345. } else {
  346. var scores = []
  347. for (var i = 0; i < 5; i++) {
  348. scores.push(Math.floor(Math.random() * 51) + 35)
  349. }
  350. this.previewScores = scores
  351. uni.setStorageSync(cacheKey, scores)
  352. }
  353. }
  354. // 等待 DOM 就绪后初始化 Canvas
  355. setTimeout(function() {
  356. self.initCanvas()
  357. }, 100)
  358. },
  359. beforeDestroy: function() {
  360. if (this._animator) {
  361. this._animator.cancel()
  362. this._animator = null
  363. }
  364. if (this._flowAnim) {
  365. this._flowAnim.stop()
  366. this._flowAnim = null
  367. }
  368. },
  369. methods: {
  370. /* ===========================
  371. 视图切换
  372. =========================== */
  373. onCenterClick: function() {
  374. // 预览模式不切换
  375. if (this.isPreview) {
  376. this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  377. return
  378. }
  379. var members = this.members
  380. if (!members || members.length === 0) {
  381. this.viewMode = 'family'
  382. this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  383. return
  384. }
  385. if (this.viewMode === 'family') {
  386. this.viewMode = 'member'
  387. } else {
  388. if (members.length === 1) {
  389. this.viewMode = 'family'
  390. this.selectedMemberIndex = 0
  391. } else {
  392. var nextIdx = this.selectedMemberIndex + 1
  393. if (nextIdx >= members.length) {
  394. this.viewMode = 'family'
  395. this.selectedMemberIndex = 0
  396. } else {
  397. this.selectedMemberIndex = nextIdx
  398. }
  399. }
  400. }
  401. this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  402. this.$emit('mode-change', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
  403. },
  404. onPointClick: function(code) {
  405. this.$emit('point-click', {
  406. code: code,
  407. viewMode: this.viewMode,
  408. member: this.currentMember
  409. })
  410. },
  411. /** 个人/全家切换(由父组件控制数据源) */
  412. onToggle: function(view) {
  413. this.viewMode = view
  414. if (view === this.currentView) return
  415. this.$emit('view-change', view)
  416. },
  417. /** 维度 code → CSS 颜色类名(不依赖 API 的 element 字段) */
  418. dimElementColor: function(code) {
  419. var meta = DIM_META[code]
  420. return meta ? meta.color : 'fire'
  421. },
  422. /* ===========================
  423. Canvas 初始化 & 渲染
  424. =========================== */
  425. initCanvas: function() {
  426. var self = this
  427. uni.createSelectorQuery().in(this).select('#starCanvas')
  428. .fields({ node: true, size: true, rect: true })
  429. .exec(function(res) {
  430. if (!res || !res[0] || !res[0].node) return
  431. var canvas = res[0].node
  432. var width = res[0].width
  433. var height = res[0].height
  434. if (!width || !height) return
  435. var pixelRatio = uni.getWindowInfo().pixelRatio || 2
  436. canvas.width = width * pixelRatio
  437. canvas.height = height * pixelRatio
  438. var ctx = canvas.getContext('2d')
  439. ctx.scale(pixelRatio, pixelRatio)
  440. self.ctx = ctx
  441. self.canvasWidth = width
  442. self.canvasHeight = height
  443. self.dpr = pixelRatio
  444. self._canvasLeft = res[0].left || 0
  445. self._canvasTop = res[0].top || 0
  446. self.canvasReady = true
  447. self._startAnimation()
  448. self._initFlowCanvas()
  449. })
  450. },
  451. _doRender: function() {
  452. if (!this.ctx) return
  453. var ts = [this.animBody, this.animMind, this.animWisdom, this.animAction, this.animWealth]
  454. renderStar(this.ctx, this.canvasWidth, this.canvasHeight, ts, {
  455. familyEnergy: this.familyEnergy,
  456. personalEnergy: this.personalEnergy
  457. })
  458. },
  459. /* ===========================
  460. 动画控制
  461. =========================== */
  462. _startAnimation: function() {
  463. var self = this
  464. if (!this._animator) {
  465. this._animator = new FillAnimator(
  466. function() {
  467. return [self.animBody, self.animMind, self.animWisdom, self.animAction, self.animWealth]
  468. },
  469. function(interpolated) {
  470. self.animBody = interpolated[0]
  471. self.animMind = interpolated[1]
  472. self.animWisdom = interpolated[2]
  473. self.animAction = interpolated[3]
  474. self.animWealth = interpolated[4]
  475. self._doRender()
  476. }
  477. )
  478. }
  479. this._animator.start(this.activeTs, 600)
  480. },
  481. /* ===========================
  482. 粒子流量动画
  483. =========================== */
  484. _initFlowCanvas: function() {
  485. var self = this
  486. var dimOrder = this.sortedDimensions.map(function(d) { return d.code })
  487. uni.createSelectorQuery().in(this).select('#flowCanvas')
  488. .fields({ node: true, size: true })
  489. .exec(function(res) {
  490. if (!res || !res[0] || !res[0].node) return
  491. var canvas = res[0].node
  492. var width = res[0].width
  493. var height = res[0].height
  494. if (!width || !height) return
  495. var pixelRatio = uni.getWindowInfo().pixelRatio || 2
  496. canvas.width = width * pixelRatio
  497. canvas.height = height * pixelRatio
  498. var ctx = canvas.getContext('2d')
  499. ctx.scale(pixelRatio, pixelRatio)
  500. var outerR = Math.min(width, height) * 0.50
  501. var vertices = calcStarVertices(width / 2, height / 2, outerR)
  502. self._flowAnim = new FlowAnimation(ctx, width, height, {
  503. outer: vertices.outer,
  504. inner: vertices.inner,
  505. dimOrder: dimOrder
  506. })
  507. self._flowAnim.start()
  508. self._flowCanvasReady = true
  509. })
  510. },
  511. /* ===========================
  512. 点击检测
  513. =========================== */
  514. onCanvasTap: function(e) {
  515. if (!this.canvasReady) return
  516. var pageX = e.detail.x
  517. var pageY = e.detail.y
  518. if (typeof pageX !== 'number') {
  519. var touch = e.touches ? e.touches[0] : null
  520. if (touch) {
  521. pageX = touch.x || touch.clientX || 0
  522. pageY = touch.y || touch.clientY || 0
  523. } else {
  524. var changedTouch = e.changedTouches ? e.changedTouches[0] : null
  525. if (changedTouch) {
  526. pageX = changedTouch.x || changedTouch.clientX || 0
  527. pageY = changedTouch.y || changedTouch.clientY || 0
  528. } else {
  529. return
  530. }
  531. }
  532. }
  533. var relX = pageX - (this._canvasLeft || 0)
  534. var relY = pageY - (this._canvasTop || 0)
  535. if (relX < 0 || relY < 0 || relX > this.canvasWidth || relY > this.canvasHeight) return
  536. var cx = this.canvasWidth / 2
  537. var cy = this.canvasHeight / 2
  538. var outerR = Math.min(this.canvasWidth, this.canvasHeight) * 0.46
  539. var armIdx = detectHitArm(relX, relY, cx, cy, outerR, calcStarVertices)
  540. if (armIdx >= 0) {
  541. this.onPointClick(armIndexToDomain(armIdx))
  542. }
  543. }
  544. }
  545. }
  546. </script>
  547. <style scoped>
  548. /* ============================================================
  549. 容器
  550. ============================================================ */
  551. .wuxing-sandbox {
  552. margin: 20rpx 30rpx;
  553. padding: 20rpx 24rpx 24rpx;
  554. background: linear-gradient(135deg, #1A2A4A, #1E3A5F, #16213E);
  555. border-radius: 24rpx;
  556. position: relative;
  557. overflow: hidden;
  558. }
  559. .wuxing-sandbox::before {
  560. content: '';
  561. position: absolute;
  562. top: -50%;
  563. left: -50%;
  564. width: 200%;
  565. height: 200%;
  566. background: radial-gradient(ellipse at 30% 20%, rgba(91, 155, 213, 0.04), transparent 60%),
  567. radial-gradient(ellipse at 70% 80%, rgba(66, 165, 245, 0.03), transparent 60%);
  568. pointer-events: none;
  569. }
  570. /* ---- 标题 ---- */
  571. .sandbox-header {
  572. display: flex;
  573. justify-content: flex-start;
  574. align-items: center;
  575. margin-bottom: 12rpx;
  576. position: relative;
  577. z-index: 5;
  578. }
  579. .sandbox-title { font-size: 26rpx; font-weight: bold; color: #8FC5E8; margin-left: 8rpx; }
  580. .sandbox-logo { width: 32rpx; height: 32rpx; flex-shrink: 0; }
  581. .sandbox-badge {
  582. font-size: 20rpx;
  583. color: rgba(143,197,232,0.7);
  584. background: rgba(143,197,232,0.1);
  585. padding: 4rpx 14rpx;
  586. border-radius: 20rpx;
  587. margin-left: auto;
  588. }
  589. /* ---- 五角星区域 ---- */
  590. .sandbox-star {
  591. position: relative;
  592. width: 100%;
  593. display: flex;
  594. justify-content: center;
  595. align-items: center;
  596. }
  597. /* 背景光晕 */
  598. .star-glow-bg {
  599. position: absolute; width: 500rpx; height: 500rpx;
  600. top: 50%; left: 50%; transform: translate(-50%, -50%);
  601. background: radial-gradient(circle, rgba(91,155,213,0.10), transparent 70%);
  602. border-radius: 50%; pointer-events: none;
  603. }
  604. /* Canvas 点击感应层 */
  605. .canvas-tap-area {
  606. position: absolute;
  607. top: 0; left: 0; right: 0; bottom: 0;
  608. z-index: 2;
  609. }
  610. /* ============================================================
  611. 顶点:图标 + 中文标签
  612. ============================================================ */
  613. .star-point {
  614. position: absolute; transform: translate(-50%, -50%); z-index: 3; cursor: pointer;
  615. display: flex; flex-direction: column; align-items: center;
  616. }
  617. /* 横排 */
  618. .star-point--row {
  619. flex-direction: row;
  620. gap: 6rpx;
  621. }
  622. .star-point--rev {
  623. flex-direction: row-reverse;
  624. gap: 6rpx;
  625. }
  626. .star-point--top { top: 1%; left: 50%; }
  627. .star-point--left { top: 38%; left: 5%; }
  628. .star-point--right { top: 38%; left: 95%; }
  629. .star-point--lr { top: 89%; left: 85%; }
  630. .star-point--ll { top: 89%; left: 15%; }
  631. .point-dot {
  632. width: 52rpx; height: 52rpx; border-radius: 50%;
  633. display: flex; align-items: center; justify-content: center;
  634. box-shadow: 0 0 24rpx rgba(255,255,255,0.25);
  635. }
  636. .point-dot--fire { background: linear-gradient(135deg, #FF6B35, #E65100); }
  637. .point-dot--water { background: linear-gradient(135deg, #42A5F5, #1565C0); }
  638. .point-dot--earth { background: linear-gradient(135deg, #8D6E63, #5D4037); }
  639. .point-dot--metal { background: linear-gradient(135deg, #FFD700, #FF8F00); }
  640. .point-dot--wood { background: linear-gradient(135deg, #4CAF50, #2E7D32); }
  641. .point-icon { font-size: 26rpx; line-height: 1; }
  642. .point-label {
  643. font-size: 22rpx; font-weight: bold; color: rgba(255,255,255,0.9);
  644. margin-top: 6rpx; text-shadow: 0 1rpx 4rpx rgba(0,0,0,0.5);
  645. letter-spacing: 2rpx;
  646. }
  647. .star-point--row .point-label,
  648. .star-point--rev .point-label {
  649. margin-top: 0;
  650. }
  651. .point-energy {
  652. font-size: 18rpx; color: rgba(143,197,232,0.8);
  653. background: rgba(143,197,232,0.12);
  654. padding: 1rpx 10rpx; border-radius: 12rpx;
  655. margin-top: 2rpx;
  656. }
  657. .star-point--row .point-energy,
  658. .star-point--rev .point-energy {
  659. margin-top: 0;
  660. }
  661. /* ============================================================
  662. 中心点击区域
  663. ============================================================ */
  664. .star-center {
  665. position: absolute; top: 50%; left: 50%;
  666. transform: translate(-50%, -50%); z-index: 4;
  667. width: 180rpx; height: 180rpx; border-radius: 50%;
  668. cursor: pointer;
  669. }
  670. /* ============================================================
  671. 维度详情条
  672. ============================================================ */
  673. .dimension-bar {
  674. display: flex; align-items: center; justify-content: center;
  675. margin-top: 16rpx; gap: 8rpx;
  676. position: relative; z-index: 5; flex-wrap: wrap;
  677. }
  678. .dim-item {
  679. font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 10rpx; white-space: nowrap;
  680. }
  681. .dim-item--wood { color: #4CAF50; background: rgba(76,175,80,0.12); }
  682. .dim-item--fire { color: #FF6B35; background: rgba(255,107,53,0.12); }
  683. .dim-item--earth { color: #8D6E63; background: rgba(141,110,99,0.12); }
  684. .dim-item--metal { color: #FFD700; background: rgba(255,215,0,0.12); }
  685. .dim-item--water { color: #42A5F5; background: rgba(66,165,245,0.12); }
  686. /* ============================================================
  687. 个人/全家切换开关
  688. ============================================================ */
  689. .sandbox-toggle {
  690. display: flex;
  691. justify-content: center;
  692. align-items: center;
  693. margin-bottom: 12rpx;
  694. position: relative;
  695. z-index: 5;
  696. gap: 0;
  697. }
  698. .toggle-item {
  699. display: flex;
  700. align-items: center;
  701. justify-content: center;
  702. padding: 8rpx 28rpx;
  703. font-size: 24rpx;
  704. color: rgba(143,197,232,0.6);
  705. background: rgba(143,197,232,0.08);
  706. border: 1rpx solid rgba(143,197,232,0.15);
  707. border-radius: 30rpx 0 0 30rpx;
  708. transition: all 0.2s;
  709. }
  710. .toggle-item--family {
  711. border-radius: 0 30rpx 30rpx 0;
  712. border-left: none;
  713. }
  714. .toggle-item--active {
  715. color: #fff;
  716. background: linear-gradient(135deg, #5B9BD5, #3A7CC2);
  717. border-color: #5B9BD5;
  718. }
  719. .toggle-icon {
  720. font-size: 26rpx;
  721. margin-right: 8rpx;
  722. }
  723. .toggle-text {
  724. font-weight: bold;
  725. }
  726. </style>