# 能量沙盘组件全面重构 实施计划 > **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: 创建目录和文件** ```js /** * 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** ```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} 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** ```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} 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** 完整替换为以下内容: ```vue ``` - [ ] **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 行: ```html ``` 改为: ```html ``` - [ ] **Step 2: 替换 safeSandbox computed** 将第 317-319 行: ```js safeSandbox() { return this.energyData || { bodyScore: 0, mindScore: 0, wisdomScore: 0, actionScore: 0, wealthScore: 0, overallScore: 0, members: [] } }, ``` 替换为 3 个 computed: ```js 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 调用方式** 当前调用: ```html ``` 验证点: - `mode="preview"` — 新接口支持,validator 允许 `'preview'` ✅ - `badgeText="登录查看完整报告"` — prop 保留 ✅ - `@point-click="handleLogin"` — event 保留 ✅ - 无 `sandbox` prop — 新接口不要求传入 ✅(组件内部 `scores` 默认全 0,`isPreview` 为 true 所以显示随机数据) **无需修改。** - [ ] **Step 2: 检查 discover/index.vue 调用方式** 当前调用与 index.vue 完全相同: ```html ``` **无需修改。** --- ### Task 7: 编译验证与最终提交 **Files:** - All modified files - [ ] **Step 1: 检查 helper 模块文件完整性** Run: ```bash ls -la cfc-frontend/components/wuxing-sandbox-helpers/ ``` Expected: 3 个 .js 文件存在(render.js, animation.js, hitdetect.js) - [ ] **Step 2: 检查主组件导入路径正确** Run: ```bash grep -n "import.*from.*wuxing-sandbox-helpers" cfc-frontend/components/wuxing-sandbox.vue ``` Expected: 3 行 import 语句,路径均指向 `./wuxing-sandbox-helpers/` - [ ] **Step 3: 检查消费页面无残留旧接口** Run: ```bash grep -rn "sandbox=" cfc-frontend/pages/ | grep -v "wuxing-sandbox.vue" | grep -v "wuxing-sandbox-helpers" ``` Expected: 无输出(消费页面不再使用 `:sandbox=` 绑定) - [ ] **Step 4: 检查死代码已清除** Run: ```bash grep -n "_calcFillPolygon\|pointInCircle\|dimVal\|curVal\|curMax\|safeSandbox" cfc-frontend/components/wuxing-sandbox.vue ``` Expected: 无输出 - [ ] **Step 5: 提交** ```bash 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` 调用 ✅