For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development 前置依赖: HBuilderX 或 Vue CLI uni-app 插件、后端 API 就绪
目标: 基于 uni-app 实现「能量盘+聊天」一体式小程序
参考代码: miniprogram/ 目录下现有微信原生代码的 UI 设计和计算逻辑
client/
├── pages/
│ ├── index/ # 首页(输入 + 能量盘 + 聊天)
│ ├── records/ # 历史能量盘列表
│ ├── profile/ # 个人中心(信息/配额/佣金)
│ └── subscribe/ # 付费页面
├── components/
│ ├── triangle/ # Classic A Canvas 渲染
│ ├── zone-card/ # 区域解读卡片
│ └── chart-input/ # 生日输入表单
├── api/
│ ├── request.js # 统一请求封装(JWT 注入)
│ ├── auth.js
│ ├── chart.js
│ ├── chat.js
│ └── payment.js
├── store/
│ └── index.js # Pinia 状态
├── utils/
│ └── calculator.js # Classic A 计算引擎
├── App.vue
├── main.js
├── pages.json
├── manifest.json
└── uni.scss
Files:
client/ 整个目录结构manifest.json(配置 AppID 为 uni-app 格式)Create: pages.json(页面路由 + TabBar)
[ ] Step 1: 创建 uni-app 项目
Run: npx degit dcloudio/uni-preset-vue#vite client 或 HBuilderX 创建
[ ] Step 2: pages.json
{
"pages": [
{"path": "pages/index/index", "style": {"navigationBarTitleText": "数字能量盘"}},
{"path": "pages/records/records", "style": {"navigationBarTitleText": "能量盘记录"}},
{"path": "pages/profile/profile", "style": {"navigationBarTitleText": "我的"}},
{"path": "pages/subscribe/subscribe", "style": {"navigationBarTitleText": "开通能量师"}}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarBackgroundColor": "#4A148C",
"backgroundColor": "#F5F5F5"
},
"tabBar": {
"color": "#999",
"selectedColor": "#4A148C",
"list": [
{"pagePath": "pages/index/index", "text": "首页", "iconPath": "static/tab/home.png"},
{"pagePath": "pages/records/records", "text": "记录", "iconPath": "static/tab/records.png"},
{"pagePath": "pages/profile/profile", "text": "我的", "iconPath": "static/tab/profile.png"}
]
}
}
[ ] Step 3: Commit
Files:
Create: utils/calculator.js
[ ] Step 1: 创建 calculator.js
export function reduceToDigit(n) {
if (n === 11 || n === 22 || n === 33) return n
while (n > 9) {
let sum = 0
while (n > 0) { sum += n % 10; n = Math.floor(n / 10) }
if (sum === 11 || sum === 22 || sum === 33) return sum
n = sum
}
return n
}
export function calculateTriangle(birthYear, birthMonth, birthDay) {
const y = String(birthYear).padStart(4, '0')
const m = String(birthMonth).padStart(2, '0')
const d = String(birthDay).padStart(2, '0')
const A = parseInt(y[0]), B = parseInt(y[1])
const C = reduceToDigit(parseInt(y[2]) + parseInt(y[3]))
const D = reduceToDigit(parseInt(m[0]) + parseInt(m[1]))
const E = reduceToDigit(parseInt(d[0]) + parseInt(d[1]))
// Row 2
const F = reduceToDigit(A + B), G = reduceToDigit(B + C)
const N = reduceToDigit(C + D), H = reduceToDigit(D + E)
// Row 3
const J = reduceToDigit(F + G), M = reduceToDigit(G + N), L = reduceToDigit(N + H)
// Row 4
const I = reduceToDigit(J + M), K = reduceToDigit(M + L)
// Top
const O = reduceToDigit(I + K)
const positions = { A, B, C, D, E, F, G, N, H, J, M, L, I, K, O }
const zones = {
mainCharacter: { name: '主性格', positions: ['O'], values: [O] },
fatherSource: { name: '父源区', positions: ['I', 'J'], values: [I, J] },
motherSource: { name: '母源区', positions: ['K', 'L'], values: [K, L] },
leftZone: { name: '左区(0-20岁)', positions: ['A', 'F', 'I', 'O'], values: [A, F, I, O] },
middleZone: { name: '中区(20-40岁)', positions: ['B', 'G', 'J', 'M', 'N', 'O'], values: [B, G, J, M, N, O] },
rightZone: { name: '右区(40-60岁)', positions: ['C', 'D', 'E', 'H', 'K', 'L', 'O'], values: [C, D, E, H, K, L, O] },
}
return {
positions, zones,
mainCharacter: O,
isMasterNumber: [11, 22, 33].includes(O)
}
}
export const NUMBER_MEANINGS = {
1: { title: '开创者', trait: '独立、创新、领导力', color: '#E53935' },
2: { title: '合作者', trait: '细腻、敏感、善于沟通', color: '#FB8C00' },
3: { title: '表达者', trait: '创意、社交、乐观向上', color: '#FDD835' },
4: { title: '建设者', trait: '务实、稳定、规则意识', color: '#43A047' },
5: { title: '自由者', trait: '冒险、变化、适应力强', color: '#1E88E5' },
6: { title: '守护者', trait: '责任、关爱、追求完美', color: '#8E24AA' },
7: { title: '探索者', trait: '深度思考、求知、内省', color: '#00ACC1' },
8: { title: '掌控者', trait: '权威、商业头脑、决策力', color: '#F4511E' },
9: { title: '智慧者', trait: '博爱、理想主义、包容', color: '#3949AB' },
11: { title: '启迪者', trait: '直觉力强、灵感丰富', color: '#FFD700' },
22: { title: '建造大师', trait: '将理想变为现实', color: '#FFD700' },
33: { title: '大爱者', trait: '无私奉献、疗愈他人', color: '#FFD700' },
}
export const POSITION_NAMES = {
A: '年位一', B: '年位二', C: '年位三', D: '月位', E: '日位',
F: '父源一', G: '中位一', N: '中位二', H: '母源一',
J: '父源二', M: '高位', L: '母源二',
I: '父源三', K: '母源三', O: '主性格',
}
[ ] Step 2: 验证
const r = calculateTriangle(1990, 1, 1)
console.log(r.mainCharacter) // 应输出 7
console.log(r.positions) // 15格数据
[ ] Step 3: Commit
Files:
api/request.jsapi/auth.jsapi/chart.jsapi/chat.jsCreate: api/payment.js
[ ] Step 1: request.js
const BASE_URL = 'https://your-api.com'
function request(path, data) {
const token = uni.getStorageSync('token')
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + path,
method: 'POST',
data,
header: {
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : ''
},
success: (res) => {
if (res.data.code === 0) resolve(res.data.data)
else if (res.data.code === 1001) {
uni.removeStorageSync('token')
uni.navigateTo({ url: '/pages/profile/profile' })
reject(res.data)
} else reject(res.data)
},
fail: reject
})
})
}
export default request
[ ] Step 2: API 模块
// api/auth.js
import request from './request'
export const login = (code, referrerCode) => request('/api/auth/login', { code, referrerCode })
// api/chart.js
export const createChart = (data) => request('/api/charts/create', data)
export const chartList = (page, size) => request('/api/charts/list', { page, size })
export const chartDetail = (id) => request('/api/charts/detail', { id })
export const deleteChart = (id) => request('/api/charts/delete', { id })
// api/chat.js
export const sendMessage = (chartId, message) => request('/api/chat/send', { chartId, message })
export const chatHistory = (chartId) => request('/api/chat/history', { chartId })
// api/payment.js
export const unifiedOrder = (productType) => request('/api/pay/unified-order', { productType })
[ ] Step 3: Commit
Files:
components/chart-input/index.vueModify: pages/index/index.vue
[ ] Step 1: chart-input/index.vue
<template>
<view class="chart-input">
<view class="hero-section">
<text class="hero-icon">✦</text>
<text class="hero-title">数字能量盘</text>
<text class="hero-desc">输入出生信息,生成你的专属数字三角形</text>
</view>
<view class="form-card">
<view class="form-group">
<text class="form-label">姓名(选填)</text>
<input class="form-input" v-model="name" placeholder="输入姓名" maxlength="20" />
</view>
<view class="form-row">
<picker class="form-group form-group--third" mode="date" fields="year" :value="yearStr" @change="onYearChange">
<view class="form-input picker-value">{{ year || '年' }}</view>
</picker>
<picker class="form-group form-group--third" mode="date" fields="month" :value="monthStr" @change="onMonthChange">
<view class="form-input picker-value">{{ month || '月' }}</view>
</picker>
<picker class="form-group form-group--third" mode="date" fields="day" :value="dayStr" @change="onDayChange">
<view class="form-input picker-value">{{ day || '日' }}</view>
</picker>
</view>
<button class="btn-primary" :class="{ 'btn-primary--disabled': !canSubmit }" :disabled="!canSubmit" @click="onGenerate">
开始测算
</button>
</view>
</view>
</template>
<script>
import { calculateTriangle } from '@/utils/calculator'
import { createChart } from '@/api/chart'
export default {
data() {
return {
name: '', year: '', month: '', day: '',
currentYear: new Date().getFullYear(),
}
},
computed: {
canSubmit() { return this.year && this.month && this.day },
yearStr() { return this.year ? `${this.year}-01-01` : '' },
monthStr() { return this.year && this.month ? `${this.year}-${this.month.padStart(2,'0')}-01` : '' },
dayStr() { return this.year && this.month && this.day ? `${this.year}-${this.month.padStart(2,'0')}-${this.day.padStart(2,'0')}` : '' },
},
methods: {
onYearChange(e) { this.year = e.detail.value.split('-')[0] },
onMonthChange(e) { const p = e.detail.value.split('-'); this.year = p[0]; this.month = p[1] },
onDayChange(e) { const p = e.detail.value.split('-'); this.year = p[0]; this.month = p[1]; this.day = p[2] },
async onGenerate() {
if (!this.canSubmit) return
const chart = calculateTriangle(parseInt(this.year), parseInt(this.month), parseInt(this.day))
try {
const res = await createChart({ name: this.name, birthYear: parseInt(this.year), birthMonth: parseInt(this.month), birthDay: parseInt(this.day) })
this.$emit('chartCreated', { id: res.id, chart, ...res })
} catch(e) {
uni.showToast({ title: '生成失败', icon: 'none' })
}
}
}
}
</script>
[ ] Step 2: Commit
Files:
Create: components/triangle/index.vue
[ ] Step 1: triangle/index.vue
<template>
<canvas type="2d" :id="canvasId" :style="`width:${width}px;height:${height}px;`" @click="onTap"></canvas>
</template>
<script>
import { NUMBER_MEANINGS, POSITION_NAMES } from '@/utils/calculator'
export default {
props: {
chartData: { type: Object, default: null },
width: { type: Number, default: 360 },
height: { type: Number, default: 480 },
canvasId: { type: String, default: 'triangleCanvas' }
},
watch: {
chartData: { immediate: true, handler(val) { if (val) this.$nextTick(() => this.draw()) } }
},
methods: {
draw() {
const query = uni.createSelectorQuery().in(this)
query.select('#' + this.canvasId).fields({ node: true, size: true }).exec((res) => {
if (!res || !res[0]) return
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = uni.getSystemInfoSync().pixelRatio
canvas.width = this.width * dpr
canvas.height = this.height * dpr
ctx.scale(dpr, dpr)
this._render(ctx, this.width, this.height, this.chartData)
})
},
_render(ctx, W, H, chart) {
if (!chart || !chart.positions) return
const p = chart.positions
// Classic A 5行布局坐标
const rows = [
{ keys: ['O'], y: 0.08, r: 28 },
{ keys: ['I','K'], y: 0.26, r: 24 },
{ keys: ['J','M','L'], y: 0.44, r: 22 },
{ keys: ['F','G','N','H'], y: 0.62, r: 20 },
{ keys: ['A','B','C','D','E'], y: 0.82, r: 18 },
]
const gapX = 50
const posMap = {}
ctx.clearRect(0, 0, W, H)
// 绘制背景
ctx.fillStyle = '#F8F5FF'
ctx.beginPath()
ctx.roundRect ? ctx.roundRect(10, 10, W-20, H-20, 16) : ctx.rect(10, 10, W-20, H-20)
ctx.fill()
// 计算坐标
rows.forEach(row => {
const total = row.keys.length * gapX
const startX = (W - total) / 2 + gapX / 2
row.keys.forEach((key, i) => {
posMap[key] = { x: startX + i * gapX, y: H * row.y, r: row.r }
})
})
// 连线
ctx.strokeStyle = '#D5C8E8'
ctx.lineWidth = 1.5
const conns = [
['A','B','F'], ['B','C','G'], ['C','D','N'], ['D','E','H'],
['F','G','J'], ['G','N','M'], ['N','H','L'],
['J','M','I'], ['M','L','K'],
['I','K','O'],
]
conns.forEach(([l,r,parent]) => {
const lp = posMap[l], rp = posMap[r], pp = posMap[parent]
if (lp && pp) { ctx.beginPath(); ctx.moveTo(lp.x, lp.y+lp.r); ctx.lineTo(pp.x, pp.y-pp.r); ctx.stroke() }
if (rp && pp) { ctx.beginPath(); ctx.moveTo(rp.x, rp.y+rp.r); ctx.lineTo(pp.x, pp.y-pp.r); ctx.stroke() }
})
// 绘制数字
rows.forEach(row => {
row.keys.forEach(key => {
const pos = posMap[key]
if (!pos) return
const val = p[key]
const meaning = NUMBER_MEANINGS[val] || {}
const isMaster = [11,22,33].includes(val)
ctx.beginPath()
ctx.arc(pos.x, pos.y, pos.r, 0, Math.PI*2)
ctx.fillStyle = isMaster ? '#FFD700' : '#FFFFFF'
ctx.fill()
ctx.strokeStyle = meaning.color || '#999'
ctx.lineWidth = isMaster ? 3 : 2
ctx.stroke()
// 卓越数标记
if (isMaster) {
ctx.fillStyle = '#E53935'
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('★', pos.x, pos.y - pos.r - 4)
}
ctx.fillStyle = meaning.color || '#333'
ctx.font = 'bold ' + (pos.r > 22 ? 22 : 18) + 'px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(String(val), pos.x, pos.y)
})
})
// 主性格标签
ctx.fillStyle = '#4A148C'
ctx.font = 'bold 14px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('主性格: ' + p['O'], W/2, 25)
},
onTap(e) {
// 点击检测同现有逻辑,触发 select 事件
// 略(参考 miniprogram/components/triangle/triangle.js 的 onTap)
}
}
}
</script>
[ ] Step 2: Commit
Files:
pages/index/index.vue(集成输入+三角+聊天一体化)Create: store/index.js
[ ] Step 1: 首页一体化
<template>
<view class="page">
<!-- 输入态 -->
<ChartInput v-if="!activeChart" @chartCreated="onChartCreated" />
<!-- 能量盘+聊天态 -->
<view v-else class="chart-chat-layout">
<!-- 顶部:能量盘 -->
<view class="chart-area">
<view class="chart-header">
<text class="chart-name">{{ chartName }}的能量盘</text>
<text class="chart-back" @click="onBack">← 重新输入</text>
</view>
<Triangle :chartData="chartData" :width="340" :height="420" />
</view>
<!-- 分割线 -->
<view class="divider"></view>
<!-- 下半区:聊天 -->
<view class="chat-area">
<scroll-view class="message-list" scroll-y :scroll-into-view="scrollTo">
<view class="msg" v-for="(msg, i) in messages" :key="i" :class="'msg--' + msg.role">
<text class="msg-label">{{ msg.role === 'assistant' ? '🤖' : '👤' }}</text>
<text class="msg-content">{{ msg.content }}</text>
</view>
<view v-if="loading" class="msg msg--assistant">
<text class="msg-label">🤖</text>
<text class="msg-content msg-loading">解读中...</text>
</view>
<view id="msg-bottom"></view>
</scroll-view>
<view class="input-bar">
<input class="chat-input" v-model="inputMsg" placeholder="询问能量盘..." @confirm="onSend" />
<button class="send-btn" @click="onSend">发送</button>
</view>
</view>
</view>
</view>
</template>
<script>
import ChartInput from '@/components/chart-input/index'
import Triangle from '@/components/triangle/index'
import { sendMessage, chatHistory } from '@/api/chat'
export default {
components: { ChartInput, Triangle },
data() {
return {
activeChart: null,
chartData: null,
chartName: '',
chartId: null,
messages: [],
inputMsg: '',
loading: false,
scrollTo: '',
}
},
methods: {
onChartCreated(res) {
this.chartId = res.id
this.chartName = res.name || '用户'
this.chartData = res.chart
this.activeChart = true
this.messages = []
// 自动发送欢迎消息
this.$nextTick(() => {
this.messages.push({
role: 'assistant',
content: `你好!我是你的数字能量助手。你的主性格数字是${res.chart.mainCharacter}(${res.chart.isMasterNumber ? '卓越数' : ''}),想了解哪方面的解读?`
})
})
},
async onSend() {
if (!this.inputMsg.trim()) return
const msg = this.inputMsg
this.inputMsg = ''
this.messages.push({ role: 'user', content: msg })
this.scrollTo = 'msg-bottom'
this.loading = true
try {
const res = await sendMessage(this.chartId, msg)
this.messages.push({ role: 'assistant', content: res.reply })
} catch(e) {
this.messages.push({ role: 'assistant', content: '解读服务异常,请稍后再试' })
}
this.loading = false
this.$nextTick(() => this.scrollTo = 'msg-bottom')
},
onBack() {
this.activeChart = false
this.chartData = null
this.messages = []
}
}
}
</script>
[ ] Step 2: Commit
Files:
pages/profile/profile.vueCreate: pages/records/records.vue(简化版,在 profile 中已含列表)
[ ] Step 1: profile/profile.vue(参考现有 miniprogram/pages/profile/ 的 UI)
<template>
<view class="page">
<view class="user-card">
<view class="user-avatar">{{ initial }}</view>
<view class="user-info">
<text class="user-name">{{ nickName || '未登录' }}</text>
<text class="user-badge" :class="isVip ? 'badge--vip' : 'badge--free'">
{{ isVip ? '能量师' : '普通用户' }}
</text>
</view>
<button v-if="!isLoggedIn" class="login-btn" @click="onLogin">微信登录</button>
</view>
<!-- VIP 状态卡 -->
<view class="card" v-if="isVip && vipExpireAt">
<text class="card-title">能量师有效期至 {{ vipExpireAt }}</text>
<text class="card-subtitle">推广码: {{ referralCode }}</text>
</view>
<!-- 免费用户配额 -->
<view class="card" v-else>
<view>今日能量盘: {{ chartCount }}/3</view>
<view>今日聊天: {{ chatCount }}/5</view>
<button @click="onSubscribe">开通能量师 ¥398/年</button>
</view>
<!-- 佣金统计 -->
<view class="card" v-if="isVip && commission">
<text class="card-title">📊 我的收益</text>
<view class="commission-row">
<view><text class="num">{{ (commission.available/100).toFixed(0) }}</text><text>可提现</text></view>
<view><text class="num">{{ (commission.totalEarnings/100).toFixed(0) }}</text><text>累计收益</text></view>
</view>
<button @click="onWithdraw" class="btn-small">提现</button>
</view>
<!-- 操作菜单 -->
<view class="menu-card">
<view class="menu-item" @click="onRecords">📋 能量盘记录</view>
<view class="menu-item" @click="onSubscribe" v-if="!isVip">⭐ 开通能量师</view>
<view class="menu-item" @click="onAbout">ℹ️ 关于我们</view>
</view>
</view>
</template>
[ ] Step 2: 历史记录列表(profile 中内联),records 页可作为单独的 Tab 页显示全部
[ ] Step 3: Commit
Files:
Create: pages/subscribe/subscribe.vue
[ ] Step 1: subscribe.vue(参考现有 miniprogram/pages/subscribe/ UI)
<template>
<view class="page">
<view class="hero-area">
<text class="price">¥398</text>
<text class="per">/年</text>
<text class="desc">每天仅 ¥1.09,让数字能量成为你的生产力</text>
</view>
<view class="benefits">
<view class="benefit-item">✅ 无限能量盘生成,不限次数</view>
<view class="benefit-item">✅ AI 五区完整解读,随问随答</view>
<view class="benefit-item">✅ 无限分享 & PDF 导出</view>
<view class="benefit-item">✅ 全部历史记录</view>
<view class="benefit-item">✅ 推广赚佣金,¥119.4/人</view>
</view>
<view class="compare-table">
<view class="compare-row header">
<text>功能</text><text>普通用户</text><text>能量师</text>
</view>
<view class="compare-row"><text>能量盘生成</text><text>3次/天</text><text>✅ 不限</text></view>
<view class="compare-row"><text>AI 解读</text><text>主性格概要</text><text>✅ 完整解读</text></view>
<view class="compare-row"><text>分享</text><text>1次/天</text><text>✅ 不限</text></view>
<view class="compare-row"><text>推广佣金</text><text>❌</text><text>✅ 最高¥159.2/人</text></view>
</view>
<button class="btn-primary pay-btn" @click="onPay" :loading="paying">
立即开通 · 能量师 ¥398/年
</button>
<text class="note">支付即表示同意《订阅协议》</text>
</view>
</template>
<script>
import { unifiedOrder } from '@/api/payment'
export default {
data() { return { paying: false } },
methods: {
async onPay() {
this.paying = true
try {
const res = await unifiedOrder('vip_yearly')
// 调起微信支付
uni.requestPayment({
provider: 'wxpay',
...res,
success: () => uni.showToast({ title: '开通成功!', icon: 'success' }),
fail: () => uni.showToast({ title: '支付取消', icon: 'none' })
})
} catch(e) { uni.showToast({ title: '支付失败', icon: 'none' }) }
this.paying = false
}
}
}
</script>
[ ] Step 2: Commit