2026-06-08-wuxing-sandbox-refactor.md 35 KB

能量沙盘组件全面重构 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 将 812 行的 wuxing-sandbox.vue 拆分为 1 个薄编排组件 + 3 个纯 JS 工具模块,重新设计 props/events/slots 接口,清理死代码,适配 3 个消费页面。

Architecture: 单组件 + 提取工具模块。主组件保留 template/CSS/Vue 逻辑,Canvas 渲染/动画/点击检测提取为纯 JS 模块。不用子组件(避免 uni-app canvas 跨组件问题),不用 mixin(避免命名冲突)。

Tech Stack: uni-app Vue 2 Options API, Canvas 2D API, 微信小程序

Spec: docs/superpowers/specs/2026-06-08-wuxing-sandbox-refactor-design.md


File Structure

cfc-frontend/
├── components/
│   ├── wuxing-sandbox.vue                    # MODIFY — 薄编排组件(812→~300行)
│   └── wuxing-sandbox-helpers/               # CREATE — 工具模块目录
│       ├── render.js                         # CREATE — Canvas 渲染函数
│       ├── animation.js                      # CREATE — 动画控制器类
│       └── hitdetect.js                      # CREATE — 点击检测工具
├── pages/index/parent-index.vue              # MODIFY — 适配新 props 接口
├── pages/index/index.vue                     # MODIFY — 无实质改动(确认兼容)
└── pages/discover/index.vue                  # MODIFY — 无实质改动(确认兼容)

Task 1: 创建 hitdetect.js — 点击检测工具模块

Files:

  • Create: cfc-frontend/components/wuxing-sandbox-helpers/hitdetect.js

  • [ ] Step 1: 创建目录和文件

    /**
    * wuxing-sandbox-helpers/hitdetect.js
    * 五角星点击检测工具(纯函数,无状态)
    */
    
    var DIM_KEYS = ['body', 'mind', 'wisdom', 'action', 'wealth']
    
    /**
    * 射线法(Ray Casting)判断点是否在任意多边形内
    * @param {number} x - 点的 x 坐标
    * @param {number} y - 点的 y 坐标
    * @param {Array<{x:number,y:number}>} polygon - 多边形顶点数组
    * @returns {boolean}
    */
    export function pointInPolygon(x, y, polygon) {
    var inside = false
    var n = polygon.length
    for (var i = 0, j = n - 1; i < n; j = i++) {
    var xi = polygon[i].x
    var yi = polygon[i].y
    var xj = polygon[j].x
    var yj = polygon[j].y
    if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
      inside = !inside
    }
    }
    return inside
    }
    
    /**
    * 计算指定星臂的多边形顶点
    * @param {number} armIndex - 星臂索引 0~4 (body/mind/wisdom/action/wealth)
    * @param {number} cx - 中心 x
    * @param {number} cy - 中心 y
    * @param {{outer:Array,inner:Array}} vertices - calcStarVertices 返回值
    * @returns {Array<{x:number,y:number}>}
    */
    export function calcArmPolygon(armIndex, cx, cy, vertices) {
    var outer = vertices.outer
    var inner = vertices.inner
    var outerIdx = (armIndex + 4) % 5
    var iBefore = (armIndex + 3) % 5
    var iAfter = (armIndex + 4) % 5
    return [
    { x: cx, y: cy },
    inner[iBefore],
    outer[outerIdx],
    inner[iAfter]
    ]
    }
    
    /**
    * 检测点击位置命中哪个星臂
    * @param {number} relX - Canvas 相对 x 坐标
    * @param {number} relY - Canvas 相对 y 坐标
    * @param {number} cx - 中心 x
    * @param {number} cy - 中心 y
    * @param {number} outerR - 外半径
    * @param {Function} calcVertices - calcStarVertices 函数引用
    * @returns {number} 命中星臂索引 0~4,-1 表示未命中
    */
    export function detectHitArm(relX, relY, cx, cy, outerR, calcVertices) {
    var vertices = calcVertices(cx, cy, outerR)
    for (var i = 0; i < 5; i++) {
    var polygon = calcArmPolygon(i, cx, cy, vertices)
    if (pointInPolygon(relX, relY, polygon)) {
      return i
    }
    }
    return -1
    }
    
    /**
    * 星臂索引 → 维度 key 映射
    * @param {number} armIndex
    * @returns {string} 'body'|'mind'|'wisdom'|'action'|'wealth'
    */
    export function armIndexToDomain(armIndex) {
    return DIM_KEYS[armIndex] || ''
    }
    
  • [ ] Step 2: 验证文件创建

Run: ls -la cfc-frontend/components/wuxing-sandbox-helpers/hitdetect.js

Expected: 文件存在,约 75 行


Task 2: 创建 render.js — Canvas 渲染工具模块

Files:

  • Create: cfc-frontend/components/wuxing-sandbox-helpers/render.js

  • [ ] Step 1: 创建 render.js

    /**
    * wuxing-sandbox-helpers/render.js
    * 五角星 Canvas 渲染(纯函数,无状态)
    */
    
    /**
    * 计算五角星顶点坐标
    * 外顶点:5 个,72° 等分,起始角度 -90°(正上方)
    * 内顶点:5 个,偏移 36°,半径 = 外半径 × 0.382
    * @param {number} cx - 中心 x
    * @param {number} cy - 中心 y
    * @param {number} outerR - 外半径
    * @returns {{outer:Array<{x:number,y:number}>,inner:Array<{x:number,y:number}>}}
    */
    export function calcStarVertices(cx, cy, outerR) {
    var innerR = outerR * 0.382
    var outer = []
    var inner = []
    for (var i = 0; i < 5; i++) {
    var angle = -Math.PI / 2 + i * 2 * Math.PI / 5
    outer.push({
      x: cx + outerR * Math.cos(angle),
      y: cy + outerR * Math.sin(angle)
    })
    var innerAngle = angle + Math.PI / 5
    inner.push({
      x: cx + innerR * Math.cos(innerAngle),
      y: cy + innerR * Math.sin(innerAngle)
    })
    }
    return { outer: outer, inner: inner }
    }
    
    /** 五色配置(与 CSS 一致) */
    var COLORS = [
    { r: 76, g: 175, b: 80 },   // 木·身
    { r: 255, g: 107, b: 53 },  // 火·心
    { r: 141, g: 110, b: 99 },  // 土·智
    { r: 255, g: 215, b: 0 },   // 金·行
    { r: 66, g: 165, b: 245 }   // 水·富
    ]
    
    /**
    * 绘制完整五角星图(含填充、描边、太极)
    * @param {CanvasRenderingContext2D} ctx
    * @param {number} w - canvas 宽度
    * @param {number} h - canvas 高度
    * @param {Array<number>} ts - 5 维归一化值 (0~1),顺序 [body,mind,wisdom,action,wealth]
    * @param {Object} [options]
    * @param {boolean} [options.showTaiji=true] - 是否绘制太极
    */
    export function renderStar(ctx, w, h, ts, options) {
    if (!ctx || !w || !h) return
    
    var opts = options || {}
    var showTaiji = opts.showTaiji !== false
    
    // 清空
    ctx.clearRect(0, 0, w, h)
    
    // 星型几何
    var cx = w / 2
    var cy = h / 2
    var outerR = Math.min(w, h) * 0.42
    var vertices = calcStarVertices(cx, cy, outerR)
    var outer = vertices.outer
    var inner = vertices.inner
    
    // 绘制每个星臂
    for (var i = 0; i < 5; i++) {
    var outerIdx = (i + 4) % 5
    var iBefore = (i + 3) % 5
    var iAfter = (i + 4) % 5
    var t = ts[i]
    var c = COLORS[i]
    
    // 星臂 polygon: center, inner[iBefore], outer[outerIdx], inner[iAfter]
    var polygon = [
      { x: cx, y: cy },
      inner[iBefore],
      outer[outerIdx],
      inner[iAfter]
    ]
    
    // --- 背景填充(低透明度) ---
    ctx.beginPath()
    ctx.moveTo(polygon[0].x, polygon[0].y)
    for (var p = 1; p < polygon.length; p++) {
      ctx.lineTo(polygon[p].x, polygon[p].y)
    }
    ctx.closePath()
    ctx.fillStyle = 'rgba(' + c.r + ', ' + c.g + ', ' + c.b + ', 0.08)'
    ctx.fill()
    
    // --- 渐变填充(t 控制色彩饱和度,覆盖整个星臂) ---
    if (t > 0.01) {
      ctx.save()
      ctx.beginPath()
      ctx.moveTo(polygon[0].x, polygon[0].y)
      for (var fp = 1; fp < polygon.length; fp++) {
        ctx.lineTo(polygon[fp].x, polygon[fp].y)
      }
      ctx.closePath()
      ctx.clip()
    
      var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(outerR, 1))
      grad.addColorStop(0, 'rgba(' + c.r + ', ' + c.g + ', ' + c.b + ', ' + (0.85 * t) + ')')
      grad.addColorStop(0.5, 'rgba(' + c.r + ', ' + c.g + ', ' + c.b + ', ' + (0.35 * t) + ')')
      grad.addColorStop(1, 'rgba(' + c.r + ', ' + c.g + ', ' + c.b + ', ' + (0.02 * t) + ')')
      ctx.fillStyle = grad
      ctx.fillRect(0, 0, w, h)
    
      ctx.restore()
    }
    
    // --- 星臂描边 ---
    ctx.beginPath()
    ctx.moveTo(polygon[0].x, polygon[0].y)
    for (var sp = 1; sp < polygon.length; sp++) {
      ctx.lineTo(polygon[sp].x, polygon[sp].y)
    }
    ctx.closePath()
    ctx.strokeStyle = 'rgba(' + c.r + ', ' + c.g + ', ' + c.b + ', 0.4)'
    ctx.lineWidth = 1.5
    ctx.stroke()
    }
    
    // --- 五角星外轮廓(10 个顶点依次连接) ---
    ctx.beginPath()
    ctx.moveTo(outer[0].x, outer[0].y)
    ctx.lineTo(inner[0].x, inner[0].y)
    ctx.lineTo(outer[1].x, outer[1].y)
    ctx.lineTo(inner[1].x, inner[1].y)
    ctx.lineTo(outer[2].x, outer[2].y)
    ctx.lineTo(inner[2].x, inner[2].y)
    ctx.lineTo(outer[3].x, outer[3].y)
    ctx.lineTo(inner[3].x, inner[3].y)
    ctx.lineTo(outer[4].x, outer[4].y)
    ctx.lineTo(inner[4].x, inner[4].y)
    ctx.closePath()
    ctx.strokeStyle = 'rgba(143, 197, 232, 0.50)'
    ctx.lineWidth = 2
    ctx.stroke()
    
    // --- 内部五边形(5 个内顶点) ---
    ctx.beginPath()
    ctx.moveTo(inner[0].x, inner[0].y)
    ctx.lineTo(inner[1].x, inner[1].y)
    ctx.lineTo(inner[2].x, inner[2].y)
    ctx.lineTo(inner[3].x, inner[3].y)
    ctx.lineTo(inner[4].x, inner[4].y)
    ctx.closePath()
    ctx.strokeStyle = 'rgba(143, 197, 232, 0.35)'
    ctx.lineWidth = 1.5
    ctx.stroke()
    
    // --- 中心太极阴阳鱼(在 Canvas 最上层绘制) ---
    if (showTaiji) {
    var taijiR = outerR * 0.22
    drawTaiji(ctx, cx, cy, taijiR)
    }
    }
    
    /**
    * 绘制太极阴阳鱼
    * @param {CanvasRenderingContext2D} ctx
    * @param {number} cx - 中心 x
    * @param {number} cy - 中心 y
    * @param {number} r - 半径
    */
    export function drawTaiji(ctx, cx, cy, r) {
    var yangColor = 'rgba(91, 155, 213, 0.35)'
    var yinColor = 'rgba(26, 42, 74, 0.75)'
    var strokeColor = 'rgba(143, 197, 232, 0.2)'
    var yangEyeColor = 'rgba(26, 42, 74, 0.85)'
    var yinEyeColor = 'rgba(143, 197, 232, 0.75)'
    
    ctx.save()
    
    // 1. 外圆背景及边框
    ctx.beginPath()
    ctx.arc(cx, cy, r, 0, Math.PI * 2)
    ctx.closePath()
    ctx.fillStyle = yangColor
    ctx.fill()
    ctx.strokeStyle = strokeColor
    ctx.lineWidth = 1
    ctx.stroke()
    
    // 2. 左半边:阴(深色)
    ctx.beginPath()
    ctx.moveTo(cx, cy)
    ctx.arc(cx, cy, r, Math.PI / 2, -Math.PI / 2)
    ctx.closePath()
    ctx.fillStyle = yinColor
    ctx.fill()
    
    // 3. 上半小圆左半弧:阳色
    ctx.beginPath()
    ctx.moveTo(cx, cy - r)
    ctx.arc(cx, cy - r / 2, r / 2, Math.PI / 2, -Math.PI / 2)
    ctx.closePath()
    ctx.fillStyle = yangColor
    ctx.fill()
    
    // 4. 下半小圆右半弧:阴色
    ctx.beginPath()
    ctx.moveTo(cx, cy + r)
    ctx.arc(cx, cy + r / 2, r / 2, -Math.PI / 2, Math.PI / 2)
    ctx.closePath()
    ctx.fillStyle = yinColor
    ctx.fill()
    
    // 5. 阳眼(深色点,上方)
    ctx.beginPath()
    ctx.arc(cx, cy - r / 2, r * 0.12, 0, Math.PI * 2)
    ctx.closePath()
    ctx.fillStyle = yangEyeColor
    ctx.fill()
    
    // 6. 阴眼(浅色点,下方)
    ctx.beginPath()
    ctx.arc(cx, cy + r / 2, r * 0.12, 0, Math.PI * 2)
    ctx.closePath()
    ctx.fillStyle = yinEyeColor
    ctx.fill()
    
    ctx.restore()
    
    // 7. 文字标签
    ctx.save()
    var fontSize = Math.round(r * 0.3)
    ctx.font = 'bold ' + fontSize + 'px sans-serif'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    
    ctx.fillStyle = 'rgba(143, 197, 232, 0.6)'
    ctx.fillText('家', cx - r * 0.3, cy + r * 0.15)
    
    ctx.fillStyle = 'rgba(91, 155, 213, 0.8)'
    ctx.fillText('个', cx + r * 0.3, cy - r * 0.15)
    
    ctx.restore()
    }
    
  • [ ] Step 2: 验证文件创建

Run: ls -la cfc-frontend/components/wuxing-sandbox-helpers/render.js

Expected: 文件存在,约 200 行


Task 3: 创建 animation.js — 动画控制器模块

Files:

  • Create: cfc-frontend/components/wuxing-sandbox-helpers/animation.js

  • [ ] Step 1: 创建 animation.js

    /**
    * wuxing-sandbox-helpers/animation.js
    * 五角星填充动画控制器
    */
    
    /**
    * FillAnimator - 管理 ease-out cubic 填充动画
    * @param {Function} getCurrentTs - 返回当前 5 维插值数组 [0~1]
    * @param {Function} onFrame - 每帧回调,参数为插值后的 ts 数组
    * @param {Function} [onComplete] - 动画完成回调
    */
    export function FillAnimator(getCurrentTs, onFrame, onComplete) {
    this._getCurrentTs = getCurrentTs
    this._onFrame = onFrame
    this._onComplete = onComplete || function() {}
    this._timer = null
    }
    
    /**
    * 启动动画
    * @param {Array<number>} targetTs - 目标 5 维值 (0~1)
    * @param {number} [duration=600] - 动画时长 ms
    */
    FillAnimator.prototype.start = function(targetTs, duration) {
    this.cancel()
    
    var dur = duration || 600
    var startTime = Date.now()
    var startTs = this._getCurrentTs()
    var self = this
    
    function step() {
    var elapsed = Date.now() - startTime
    var progress = Math.min(elapsed / dur, 1)
    // ease-out cubic
    var eased = 1 - Math.pow(1 - progress, 3)
    
    var interpolated = []
    for (var i = 0; i < 5; i++) {
      interpolated.push(startTs[i] + (targetTs[i] - startTs[i]) * eased)
    }
    
    self._onFrame(interpolated)
    
    if (progress < 1) {
      self._timer = setTimeout(step, 16)
    } else {
      self._timer = null
      self._onComplete()
    }
    }
    
    step()
    }
    
    /**
    * 取消动画,清理 timer
    */
    FillAnimator.prototype.cancel = function() {
    if (this._timer) {
    clearTimeout(this._timer)
    this._timer = null
    }
    }
    
  • [ ] Step 2: 验证文件创建

Run: ls -la cfc-frontend/components/wuxing-sandbox-helpers/animation.js

Expected: 文件存在,约 65 行


Task 4: 重写 wuxing-sandbox.vue — 主组件重构

Files:

  • Modify: cfc-frontend/components/wuxing-sandbox.vue (全文重写)

这是最核心的任务。将 812 行组件重写为 ~300 行薄编排组件,导入 3 个 helper 模块,重新设计 props/events/slots。

  • Step 1: 重写 wuxing-sandbox.vue

完整替换为以下内容:

<template>
<view class="wuxing-sandbox">
  <!-- header slot -->
  <slot name="header">
    <view class="sandbox-header">
      <text class="sandbox-title">🌟 家庭能量沙盘</text>
      <text class="sandbox-badge">{{ displayBadgeText }}</text>
    </view>
  </slot>

  <view class="sandbox-star" :style="{height: starHeight}">
    <view class="star-glow-bg"></view>

    <!-- Canvas 2D 五行星图 -->
    <canvas id="starCanvas" type="2d" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1"></canvas>

    <!-- Canvas 点击感应层 -->
    <view class="canvas-tap-area" @click="onCanvasTap"></view>

    <!-- 顶点:图标 + 标签 身/心/智/行/财 -->
    <view class="star-point star-point--ul" @click="onPointClick('body')">
      <view class="point-dot point-dot--wood"><text class="point-icon">🌿</text></view>
      <text class="point-label">身</text>
    </view>
    <view class="star-point star-point--top" @click="onPointClick('mind')">
      <view class="point-dot point-dot--fire"><text class="point-icon">🔥</text></view>
      <text class="point-label">心</text>
    </view>
    <view class="star-point star-point--ur" @click="onPointClick('wealth')">
      <view class="point-dot point-dot--earth"><text class="point-icon">🌏</text></view>
      <text class="point-label">财</text>
    </view>
    <view class="star-point star-point--lr" @click="onPointClick('action')">
      <view class="point-dot point-dot--metal"><text class="point-icon">⚡</text></view>
      <text class="point-label">行</text>
    </view>
    <view class="star-point star-point--ll" @click="onPointClick('wisdom')">
      <view class="point-dot point-dot--water"><text class="point-icon">💧</text></view>
      <text class="point-label">智</text>
    </view>

    <!-- 中心点击区域(太极由 Canvas 绘制) -->
    <view class="star-center" @click="onCenterClick">
      <text class="energy-hint">{{ viewMode === 'family' ? '家庭能量' : (currentMember ? currentMember.name : '个人') }}</text>
    </view>
  </view>

  <!-- dimension-bar slot -->
  <slot name="dimension-bar" v-if="showDimensionBar && mode !== 'preview'">
    <view class="dimension-bar">
      <view class="dim-item dim-item--wood">🌿身 {{ activeScores[0] }}%</view>
      <view class="dim-item dim-item--fire">🔥心 {{ activeScores[1] }}%</view>
      <view class="dim-item dim-item--earth">🌏智 {{ activeScores[2] }}%</view>
      <view class="dim-item dim-item--metal">⚡行 {{ activeScores[3] }}%</view>
      <view class="dim-item dim-item--water">💧富 {{ activeScores[4] }}%</view>
    </view>
  </slot>
</view>
</template>

<script>
import { renderStar, calcStarVertices } from './wuxing-sandbox-helpers/render.js'
import { FillAnimator } from './wuxing-sandbox-helpers/animation.js'
import { detectHitArm, armIndexToDomain } from './wuxing-sandbox-helpers/hitdetect.js'

var DIM_KEYS = ['body', 'mind', 'wisdom', 'action', 'wealth']

export default {
  props: {
    /** 模式:'preview' 随机数据 / 'live' 真实数据 */
    mode: {
      type: String,
      default: 'preview',
      validator: function(v) { return v === 'preview' || v === 'live' }
    },
    /** 五维分值对象 {body,mind,wisdom,action,wealth},0-100 */
    scores: {
      type: Object,
      default: function() { return { body: 0, mind: 0, wisdom: 0, action: 0, wealth: 0 } }
    },
    /** 综合分值 0-100 */
    overallScore: {
      type: Number,
      default: 0
    },
    /** 家庭成员数组(MemberEnergyDTO 格式) */
    members: {
      type: Array,
      default: function() { return [] }
    },
    /** 顶部徽章文字,空则自动计算 */
    badgeText: {
      type: String,
      default: ''
    },
    /** 是否显示底部维度条 */
    showDimensionBar: {
      type: Boolean,
      default: true
    },
    /** 星图区域高度 */
    starHeight: {
      type: String,
      default: '480rpx'
    }
  },
  data: function() {
    return {
      viewMode: 'family',
      selectedMemberIndex: 0,
      previewScores: [0, 0, 0, 0, 0],
      canvasReady: false,
      canvasWidth: 0,
      canvasHeight: 0,
      ctx: null,
      dpr: 1,
      _canvasLeft: 0,
      _canvasTop: 0,
      animBody: 0,
      animMind: 0,
      animWisdom: 0,
      animAction: 0,
      animWealth: 0,
      _animator: null
    }
  },
  computed: {
    isPreview: function() {
      return this.mode === 'preview'
    },
    currentMember: function() {
      if (this.isPreview) return null
      var m = this.members
      if (!m || m.length === 0) return null
      if (this.selectedMemberIndex >= m.length) return m[0]
      return m[this.selectedMemberIndex]
    },
    /** 当前活跃的五维值数组 [body, mind, wisdom, action, wealth] */
    activeScores: function() {
      if (this.isPreview) return this.previewScores
      if (this.viewMode === 'family') {
        var s = this.scores
        return [
          s.body || 0,
          s.mind || 0,
          s.wisdom || 0,
          s.action || 0,
          s.wealth || 0
        ]
      }
      var m = this.currentMember
      if (!m) return [0, 0, 0, 0, 0]
      return [
        m.bodyScore || 0,
        m.mindScore || 0,
        m.wisdomScore || 0,
        m.actionScore || 0,
        m.wealthScore || 0
      ]
    },
    activeOverall: function() {
      if (this.isPreview) {
        var s = this.previewScores
        return Math.round((s[0] + s[1] + s[2] + s[3] + s[4]) / 5)
      }
      if (this.viewMode === 'family') return this.overallScore || 0
      var m = this.currentMember
      return m ? (m.overallScore || 0) : 0
    },
    activeTs: function() {
      return [
        this.activeScores[0] / 100,
        this.activeScores[1] / 100,
        this.activeScores[2] / 100,
        this.activeScores[3] / 100,
        this.activeScores[4] / 100
      ]
    },
    displayBadgeText: function() {
      if (this.badgeText) return this.badgeText
      var label = this.viewMode === 'family' ? '家庭能量' : (this.currentMember ? this.currentMember.name : '个人能量')
      return label + ' ' + (this.activeOverall || 0) + '%'
    },
    _allDimTs: function() {
      return this.activeTs
    },
    hasMultipleMembers: function() {
      return this.members && this.members.length > 1
    }
  },
  watch: {
    _allDimTs: function() {
      if (this.canvasReady) {
        this._startAnimation()
      }
    }
  },
  mounted: function() {
    var self = this

    // 预览模式:生成随机五维值
    if (this.isPreview) {
      var scores = []
      for (var i = 0; i < 5; i++) {
        scores.push(Math.floor(Math.random() * 51) + 35)
      }
      this.previewScores = scores
    }

    // 等待 DOM 就绪后初始化 Canvas
    setTimeout(function() {
      self.initCanvas()
    }, 100)
  },
  beforeDestroy: function() {
    if (this._animator) {
      this._animator.cancel()
      this._animator = null
    }
  },
  methods: {
    /* ===========================
       视图切换
    =========================== */

    onCenterClick: function() {
      // 预览模式不切换
      if (this.isPreview) {
        this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
        return
      }
      var members = this.members
      if (!members || members.length === 0) {
        this.viewMode = 'family'
        this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
        return
      }
      if (this.viewMode === 'family') {
        this.viewMode = 'member'
      } else {
        if (members.length === 1) {
          this.viewMode = 'family'
          this.selectedMemberIndex = 0
        } else {
          var nextIdx = this.selectedMemberIndex + 1
          if (nextIdx >= members.length) {
            this.viewMode = 'family'
            this.selectedMemberIndex = 0
          } else {
            this.selectedMemberIndex = nextIdx
          }
        }
      }
      this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
      this.$emit('mode-change', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
    },

    onPointClick: function(domain) {
      this.$emit('point-click', {
        domain: domain,
        viewMode: this.viewMode,
        member: this.currentMember
      })
    },

    /* ===========================
       Canvas 初始化 & 渲染
    =========================== */

    initCanvas: function() {
      var self = this
      uni.createSelectorQuery().in(this).select('#starCanvas')
        .fields({ node: true, size: true, rect: true })
        .exec(function(res) {
          if (!res || !res[0] || !res[0].node) return

          var canvas = res[0].node
          var width = res[0].width
          var height = res[0].height
          if (!width || !height) return

          var pixelRatio = uni.getSystemInfoSync().pixelRatio || 2

          canvas.width = width * pixelRatio
          canvas.height = height * pixelRatio

          var ctx = canvas.getContext('2d')
          ctx.scale(pixelRatio, pixelRatio)

          self.ctx = ctx
          self.canvasWidth = width
          self.canvasHeight = height
          self.dpr = pixelRatio
          self._canvasLeft = res[0].left || 0
          self._canvasTop = res[0].top || 0
          self.canvasReady = true

          self._startAnimation()
        })
    },

    _doRender: function() {
      if (!this.ctx) return
      var ts = [this.animBody, this.animMind, this.animWisdom, this.animAction, this.animWealth]
      renderStar(this.ctx, this.canvasWidth, this.canvasHeight, ts)
    },

    /* ===========================
       动画控制
    =========================== */

    _startAnimation: function() {
      var self = this
      if (!this._animator) {
        this._animator = new FillAnimator(
          function() {
            return [self.animBody, self.animMind, self.animWisdom, self.animAction, self.animWealth]
          },
          function(interpolated) {
            self.animBody = interpolated[0]
            self.animMind = interpolated[1]
            self.animWisdom = interpolated[2]
            self.animAction = interpolated[3]
            self.animWealth = interpolated[4]
            self._doRender()
          }
        )
      }
      this._animator.start(this.activeTs, 600)
    },

    /* ===========================
       点击检测
    =========================== */

    onCanvasTap: function(e) {
      if (!this.canvasReady) return

      var pageX = e.detail.x
      var pageY = e.detail.y
      if (typeof pageX !== 'number') {
        var touch = e.touches ? e.touches[0] : null
        if (touch) {
          pageX = touch.x || touch.clientX || 0
          pageY = touch.y || touch.clientY || 0
        } else {
          var changedTouch = e.changedTouches ? e.changedTouches[0] : null
          if (changedTouch) {
            pageX = changedTouch.x || changedTouch.clientX || 0
            pageY = changedTouch.y || changedTouch.clientY || 0
          } else {
            return
          }
        }
      }

      var relX = pageX - (this._canvasLeft || 0)
      var relY = pageY - (this._canvasTop || 0)
      if (relX < 0 || relY < 0 || relX > this.canvasWidth || relY > this.canvasHeight) return

      var cx = this.canvasWidth / 2
      var cy = this.canvasHeight / 2
      var outerR = Math.min(this.canvasWidth, this.canvasHeight) * 0.42
      var armIdx = detectHitArm(relX, relY, cx, cy, outerR, calcStarVertices)
      if (armIdx >= 0) {
        this.onPointClick(armIndexToDomain(armIdx))
      }
    }
  }
}
</script>

<style scoped>
/* ============================================================
   容器
   ============================================================ */
.wuxing-sandbox {
  margin: 20rpx 30rpx;
  padding: 20rpx 24rpx 24rpx;
  background: linear-gradient(135deg, #1A2A4A, #1E3A5F, #16213E);
  border-radius: 24rpx;
  position: relative;
  overflow: hidden;
}
.wuxing-sandbox::before {
  content: '';
  position: absolute;
  top: -50%;
  left: -50%;
  width: 200%;
  height: 200%;
  background: radial-gradient(ellipse at 30% 20%, rgba(91, 155, 213, 0.04), transparent 60%),
    radial-gradient(ellipse at 70% 80%, rgba(66, 165, 245, 0.03), transparent 60%);
  pointer-events: none;
}

/* ---- 标题 ---- */
.sandbox-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12rpx;
  position: relative;
  z-index: 5;
}
.sandbox-title { font-size: 26rpx; font-weight: bold; color: #8FC5E8; }
.sandbox-badge {
  font-size: 20rpx;
  color: rgba(143,197,232,0.7);
  background: rgba(143,197,232,0.1);
  padding: 4rpx 14rpx;
  border-radius: 20rpx;
}

/* ---- 五角星区域 ---- */
.sandbox-star {
  position: relative;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

/* 背景光晕 */
.star-glow-bg {
  position: absolute; width: 440rpx; height: 440rpx;
  top: 50%; left: 50%; transform: translate(-50%, -50%);
  background: radial-gradient(circle, rgba(91,155,213,0.10), transparent 70%);
  border-radius: 50%; pointer-events: none;
}

/* Canvas 点击感应层 */
.canvas-tap-area {
  position: absolute;
  top: 0; left: 0; right: 0; bottom: 0;
  z-index: 2;
}

/* ============================================================
   顶点:图标 + 中文标签
   ============================================================ */
.star-point {
  position: absolute; transform: translate(-50%, -50%); z-index: 3; cursor: pointer;
  display: flex; flex-direction: column; align-items: center;
}
.star-point--top { top: 3%; left: 50%; }
.star-point--ur { top: 26%; left: 87%; }
.star-point--lr { top: 73%; left: 75%; }
.star-point--ll { top: 73%; left: 25%; }
.star-point--ul { top: 26%; left: 13%; }

.point-dot {
  width: 52rpx; height: 52rpx; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 0 24rpx rgba(255,255,255,0.25);
}
.point-dot--fire { background: linear-gradient(135deg, #FF6B35, #E65100); }
.point-dot--water { background: linear-gradient(135deg, #42A5F5, #1565C0); }
.point-dot--earth { background: linear-gradient(135deg, #8D6E63, #5D4037); }
.point-dot--metal { background: linear-gradient(135deg, #FFD700, #FF8F00); }
.point-dot--wood { background: linear-gradient(135deg, #4CAF50, #2E7D32); }

.point-icon { font-size: 26rpx; line-height: 1; }

.point-label {
  font-size: 22rpx; font-weight: bold; color: rgba(255,255,255,0.9);
  margin-top: 6rpx; text-shadow: 0 1rpx 4rpx rgba(0,0,0,0.5);
  letter-spacing: 2rpx;
}

/* ============================================================
   中心点击区域
   ============================================================ */
.star-center {
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%); z-index: 4;
  width: 130rpx; height: 130rpx; border-radius: 50%;
  cursor: pointer;
}

.energy-hint {
  position: absolute; bottom: -30rpx; left: 50%;
  transform: translateX(-50%);
  font-size: 18rpx; color: rgba(143,197,232,0.4);
  white-space: nowrap;
}

/* ============================================================
   维度详情条
   ============================================================ */
.dimension-bar {
  display: flex; align-items: center; justify-content: center;
  margin-top: 16rpx; gap: 8rpx;
  position: relative; z-index: 5; flex-wrap: wrap;
}
.dim-item {
  font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 10rpx; white-space: nowrap;
}
.dim-item--wood { color: #4CAF50; background: rgba(76,175,80,0.12); }
.dim-item--fire { color: #FF6B35; background: rgba(255,107,53,0.12); }
.dim-item--earth { color: #8D6E63; background: rgba(141,110,99,0.12); }
.dim-item--metal { color: #FFD700; background: rgba(255,215,0,0.12); }
.dim-item--water { color: #42A5F5; background: rgba(66,165,245,0.12); }
</style>
  • Step 2: 检查 LSP 诊断

Run: n/a(前端 .vue 文件暂无 LSP,改为手动检查导入路径正确性)

手动验证:

  • import { renderStar, calcStarVertices } from './wuxing-sandbox-helpers/render.js' → 文件需在 Task 2 创建
  • import { FillAnimator } from './wuxing-sandbox-helpers/animation.js' → 文件需在 Task 3 创建
  • import { detectHitArm, armIndexToDomain } from './wuxing-sandbox-helpers/hitdetect.js' → 文件需在 Task 1 创建
  • sandbox-star 高度改为 :style="{height: starHeight}" 绑定,默认 480rpx 不变
  • 删除了 sandbox prop,新增 scores/overallScore/members/showDimensionBar/starHeight
  • 新增 @center-click 和 @mode-change 事件
  • 新增 header 和 dimension-bar slots
  • 删除死代码:_calcFillPolygon、pointInCircle、dimVal、curVal、curMax
  • _fillTimer 替换为 _animator(FillAnimator 实例)
  • toggleEnergyMode 改名为 onCenterClick,增加了事件发射

Task 5: 适配 parent-index.vue — 消费页面改造

Files:

  • Modify: cfc-frontend/pages/index/parent-index.vue

  • [ ] Step 1: 修改模板中的组件调用

将第 82-84 行:

<wuxing-sandbox mode="parent"
  :sandbox="safeSandbox"
  @point-click="goToDomain" />

改为:

<wuxing-sandbox mode="live"
  :scores="energyScores"
  :overall-score="energyOverall"
  :members="energyMembers"
  @point-click="goToDomain" />
  • Step 2: 替换 safeSandbox computed

将第 317-319 行:

safeSandbox() {
  return this.energyData || { bodyScore: 0, mindScore: 0, wisdomScore: 0, actionScore: 0, wealthScore: 0, overallScore: 0, members: [] }
},

替换为 3 个 computed:

energyScores() {
  var d = this.energyData
  if (!d) return { body: 0, mind: 0, wisdom: 0, action: 0, wealth: 0 }
  return {
    body: d.bodyScore || 0,
    mind: d.mindScore || 0,
    wisdom: d.wisdomScore || 0,
    action: d.actionScore || 0,
    wealth: d.wealthScore || 0
  }
},
energyOverall() {
  return (this.energyData && this.energyData.overallScore) || 0
},
energyMembers() {
  return (this.energyData && this.energyData.members) || []
},
  • Step 3: 验证没有残留 safeSandbox 引用

Run: grep -n "safeSandbox" cfc-frontend/pages/index/parent-index.vue

Expected: 无输出(所有引用已清除)


Task 6: 确认 index.vue 和 discover/index.vue 兼容

Files:

  • Verify: cfc-frontend/pages/index/index.vue
  • Verify: cfc-frontend/pages/discover/index.vue

  • [ ] Step 1: 检查 index.vue 调用方式

当前调用:

<wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />

验证点:

  • mode="preview" — 新接口支持,validator 允许 'preview' ✅
  • badgeText="登录查看完整报告" — prop 保留 ✅
  • @point-click="handleLogin" — event 保留 ✅
  • 无 sandbox prop — 新接口不要求传入 ✅(组件内部 scores 默认全 0,isPreview 为 true 所以显示随机数据)

无需修改。

  • Step 2: 检查 discover/index.vue 调用方式

当前调用与 index.vue 完全相同:

<wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />

无需修改。


Task 7: 编译验证与最终提交

Files:

  • All modified files

  • [ ] Step 1: 检查 helper 模块文件完整性

Run:

ls -la cfc-frontend/components/wuxing-sandbox-helpers/

Expected: 3 个 .js 文件存在(render.js, animation.js, hitdetect.js)

  • Step 2: 检查主组件导入路径正确

Run:

grep -n "import.*from.*wuxing-sandbox-helpers" cfc-frontend/components/wuxing-sandbox.vue

Expected: 3 行 import 语句,路径均指向 ./wuxing-sandbox-helpers/

  • Step 3: 检查消费页面无残留旧接口

Run:

grep -rn "sandbox=" cfc-frontend/pages/ | grep -v "wuxing-sandbox.vue" | grep -v "wuxing-sandbox-helpers"

Expected: 无输出(消费页面不再使用 :sandbox= 绑定)

  • Step 4: 检查死代码已清除

Run:

grep -n "_calcFillPolygon\|pointInCircle\|dimVal\|curVal\|curMax\|safeSandbox" cfc-frontend/components/wuxing-sandbox.vue

Expected: 无输出

  • [ ] Step 5: 提交

    git add cfc-frontend/components/wuxing-sandbox.vue \
        cfc-frontend/components/wuxing-sandbox-helpers/ \
        cfc-frontend/pages/index/parent-index.vue \
        docs/superpowers/specs/2026-06-08-wuxing-sandbox-refactor-design.md \
        docs/superpowers/plans/2026-06-08-wuxing-sandbox-refactor.md
    git commit -m "refactor: 全面重构能量沙盘组件 - 拆分为3个helper模块 + 扁平化props接口 + 清理死代码"
    

Self-Review

1. Spec 覆盖度检查:

  • ✅ 拆分为 1 组件 + 3 helper 模块 → Task 1-4
  • ✅ Props 扁平化(scores/overallScore/members)→ Task 4
  • ✅ Events(center-click/mode-change)→ Task 4
  • ✅ Slots(header/dimension-bar)→ Task 4
  • ✅ 死代码清理(5 个方法)→ Task 4
  • ✅ 消费页面适配 → Task 5-6
  • ✅ 导入路径不变 → Task 6 验证

2. 占位符扫描: 无 TBD/TODO/类似后补

3. 类型一致性检查:

  • renderStar(ctx, w, h, ts, options) — Task 2 定义,Task 4 调用 renderStar(this.ctx, this.canvasWidth, this.canvasHeight, ts) ✅
  • calcStarVertices(cx, cy, outerR) — Task 2 导出,Task 4 在 onCanvasTap 中传给 detectHitArm ✅
  • detectHitArm(relX, relY, cx, cy, outerR, calcVertices) — Task 1 定义,Task 4 调用 ✅
  • armIndexToDomain(armIdx) — Task 1 定义,Task 4 调用 ✅
  • FillAnimator(getCurrentTs, onFrame, onComplete) — Task 3 定义,Task 4 实例化 ✅
  • FillAnimator.prototype.start(targetTs, duration) — Task 3 定义,Task 4 调用 ✅
  • FillAnimator.prototype.cancel() — Task 3 定义,Task 4 的 beforeDestroy 调用 ✅