2026-05-26-miniprogram-uniapp.md 23 KB

Sub-Plan C: uni-app 小程序开发

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

Task 1: 项目骨架搭建

Files:

  • Create: client/ 整个目录结构
  • Create: 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


Task 2: Classic A 计算引擎(迁移适配)

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


Task 3: API 层封装

Files:

  • Create: api/request.js
  • Create: api/auth.js
  • Create: api/chart.js
  • Create: api/chat.js
  • Create: 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


Task 4: 首页 — 输入组件

Files:

  • Create: components/chart-input/index.vue
  • Modify: 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


Task 5: 首页 — Class A Canvas 三角组件

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


Task 6: 首页 — 聊天窗口

Files:

  • Modify: 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


Task 7: 个人中心 + 历史记录

Files:

  • Create: pages/profile/profile.vue
  • Create: 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


Task 8: 付费订阅页

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