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
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 — 无实质改动(确认兼容)
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 行
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 行
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 行
Files:
cfc-frontend/components/wuxing-sandbox.vue (全文重写)这是最核心的任务。将 812 行组件重写为 ~300 行薄编排组件,导入 3 个 helper 模块,重新设计 props/events/slots。
完整替换为以下内容:
<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>
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,增加了事件发射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" />
将第 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) || []
},
Run: grep -n "safeSandbox" cfc-frontend/pages/index/parent-index.vue
Expected: 无输出(所有引用已清除)
Files:
cfc-frontend/pages/index/index.vueVerify: 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 所以显示随机数据)无需修改。
当前调用与 index.vue 完全相同:
<wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />
无需修改。
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)
Run:
grep -n "import.*from.*wuxing-sandbox-helpers" cfc-frontend/components/wuxing-sandbox.vue
Expected: 3 行 import 语句,路径均指向 ./wuxing-sandbox-helpers/
Run:
grep -rn "sandbox=" cfc-frontend/pages/ | grep -v "wuxing-sandbox.vue" | grep -v "wuxing-sandbox-helpers"
Expected: 无输出(消费页面不再使用 :sandbox= 绑定)
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接口 + 清理死代码"
1. Spec 覆盖度检查:
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 调用 ✅