分销代理多端触达系统 · 二次开发教程(05)

📌 转载声明:本文为《分销代理多端触达系统 · 二次开发教程》系列第 05 篇。转载请注明出处;可运行的配套代码与最新版本见官方开源仓库(Apache 2.0):atomgit.com/pangzi_zi/distributor-touch

UniApp 小程序二开

上一篇:04 C 端商城与跨店铺拆单 本篇目标:能独立加页面、接接口、加语言、编译到四端。 目录:uniapp/ · 框架 uni-app (Vue 3) + Vite · 一套代码编译 H5 / 微信小程序 / App / 企微侧边栏

一、第一件事:目录结构变了

如果你之前用过老版 uni-app,注意这个版本强制 vite 风格目录 —— 源码必须放在 src/ 下。

uniapp/
├── vite.config.js          # ★ Vite 配置(H5 产物 base: '/mp/')
├── package.json
├── index.html              # H5 入口模板(含企微 jsapi 注入)
├── scripts/
│   ├── gen-tabbar-icons.cjs       # 零依赖生成 tabBar 图标
│   ├── inject-dashboard-i18n.cjs  # 批量注入 i18n 词条
│   └── clean-dist.cjs             # 构建前归档旧产物(见 §7.2 的坑)
└── src/                    # ★ 所有源码在这
    ├── main.js  App.vue
    ├── pages.json          # 路由 + tabBar
    ├── manifest.json       # 多端编译配置
    ├── uni.scss            # 全局 SCSS 变量
    ├── utils/
    │   ├── api.js          # API 封装 + 离线占位数据
    │   ├── auth.js         # 登录态
    │   ├── cart.js         # 购物车 + 分组 + 满减
    │   ├── config.js       # 环境判定 / API_BASE / DEMO_MODE
    │   ├── i18n.js         # 7 语种字典
    │   └── nav.js          # 跳转统一入口
    ├── pages/
    │   ├── login/  index/  commission/  materials/
    │   ├── training/  posters/  report/  agent/  mine/
    │   └── shop/            # C 端商城 5 页
    ├── components/
    └── static/tabbar/      # 图标(由脚本生成)

迁移清单(从老结构迁过来):

  • [ ] main.js / App.vue / manifest.json / pages.json / uni.scss 移进 src/
  • [ ] pages/ components/ utils/ static/ 移进 src/
  • [ ] 新增 vite.config.js
  • [ ] index.html 的 script src 改成 /src/main.js

二、依赖版本:一个容易撞墙的点

{
  "dependencies": {
    "@dcloudio/uni-app": "3.0.0-alpha-5020520260824002",
    "@dcloudio/uni-components": "3.0.0-alpha-5020520260824002",
    "@dcloudio/uni-h5": "3.0.0-alpha-5020520260824002",
    "@dcloudio/uni-mp-weixin": "3.0.0-alpha-5020520260824002",
    "vue": "^3.4.0"
  },
  "devDependencies": {
    "@dcloudio/uni-cli-shared": "3.0.0-alpha-5020520260824002",
    "@dcloudio/vite-plugin-uni": "3.0.0-alpha-5020520260824002",
    "vite": "^5.0.0"
  }
}
⚠️ 别写 ^3.0.0 —— npm 上 uni-app 的 3.x 只有 alpha 版本,没有 stable,npm install 会直接 ETARGET 报错。 必须用 vue dist-tag 上对应的完整 alpha 版本号,且所有 @dcloudio/* 版本必须完全一致,否则编译时报各种诡异的模块找不到。

vite.config.js:

import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig({
  plugins: [uni()],
  // H5 产物挂在官网 /mp/ 路径下,用绝对路径前缀
  base: process.env.UNI_PLATFORM === 'h5' ? '/mp/' : '/',
});

三、加一个页面:三步

步骤 1:建 vue 文件

src/pages/shop/coupon.vue(以「我的优惠券」为例):

<template>
  <view class="page">
    <view v-if="loading" class="loading">{{ t('common.loading') }}</view>

    <view v-else-if="list.length === 0" class="empty">
      <text class="empty-icon">🎫</text>
      <text>{{ t('coupon.empty') }}</text>
    </view>

    <view v-else class="coupon-list">
      <view class="coupon-item" v-for="c in list" :key="c.id">
        <view class="coupon-left">
          <text class="coupon-amount">¥{{ money(c.amount) }}</text>
          <text class="coupon-threshold">满 {{ money(c.threshold) }} 可用</text>
        </view>
        <view class="coupon-right">
          <text class="coupon-name">{{ c.name }}</text>
          <text class="coupon-expire">{{ t('coupon.expireAt') }} {{ c.expireAt }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { t } from '../../utils/i18n.js';
import { getCoupons } from '../../utils/api.js';
import { money } from '../../utils/cart.js';

export default {
  data() {
    return { loading: true, list: [] };
  },
  onLoad() { this.loadData(); },
  onPullDownRefresh() {
    this.loadData().then(() => uni.stopPullDownRefresh());
  },
  methods: {
    t,
    money,
    async loadData() {
      this.loading = true;
      const res = await getCoupons();
      if (res.ok && res.data) {
        this.list = res.data.items || [];
      }
      this.loading = false;
    },
  },
};
</script>

<style scoped>
.page { padding: 24rpx; background: #f5f6fa; min-height: 100vh; }
.coupon-item {
  display: flex; align-items: center; gap: 24rpx;
  background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx;
}
.coupon-amount { font-size: 44rpx; font-weight: 700; color: #dc2626; }
</style>

要点:

  • 单位用 rpx(750rpx = 屏幕宽度),uni-app 会自动换算
  • t 和工具函数要在 methods 里挂出来才能在模板用
  • <style scoped> 防止样式污染

步骤 2:在 pages.json 注册

{
  "pages": [
    // ... 已有页面
    {
      "path": "pages/shop/coupon",
      "style": {
        "navigationBarTitleText": "我的优惠券",
        "enablePullDownRefresh": true
      }
    }
  ]
}

注意:路径不带 .vue 后缀,且不以 / 开头。

style 常用字段:

字段说明
navigationBarTitleText导航栏标题
navigationStylecustom = 隐藏原生导航栏(自绘时用)
enablePullDownRefresh开启下拉刷新(配合 onPullDownRefresh)

步骤 3:跳过去

import { goto } from '../../utils/nav.js';

goto('/pages/shop/coupon');
goto(`/pages/shop/product?id=${p.id}`);
⚠️ 千万别直接 uni.navigateTo 跳 tabBar 页 —— 会静默失败(不报错也不跳转),排查起来很痛苦。 utils/nav.js 封装了这个判断: ``js const TAB_PAGES = new Set([ 'pages/index/index', 'pages/shop/index', 'pages/materials/index', 'pages/commission/index', 'pages/mine/index', ]); export function goto(url) { if (!url) return false; const path = String(url).split('?')[0].replace(/^\//, ''); try { if (TAB_PAGES.has(path)) { uni.switchTab({ url, fail: () => uni.navigateTo({ url }) }); } else { uni.navigateTo({ url }); } return true; } catch (e) { console.warn('[nav] goto failed:', url, e); return false; } } ` **加了新 tabBar 页,记得同步更新 TAB_PAGES**,否则跳转会掉到 navigateTo` 然后失败。

四、API 层:离线降级 + 演示模式

utils/api.js 的设计是三层的:

请求 → 正常返回? ──是──► 用真数据
        │
        否(网络失败/后端挂了)
        ▼
   离线占位数据(PLACEHOLDERS)

4.1 基础请求

const BASE_URL = (() => {
  // #ifdef H5
  return '/api';                        // H5 由 nginx 反代,同源无跨域
  // #endif
  // #ifndef H5
  return 'http://localhost:3000/api';   // 小程序/App 直连
  // #endif
})();

// #ifdef H5 是 uni-app 的条件编译,构建时按平台剔除代码。

4.2 占位数据

const PLACEHOLDERS = {
  dashboard: {
    agentName: '张代理(示例)',
    metrics: [ /* ... */ ],
    todos: [ /* ... */ ],
  },
  commission: { /* ... */ },
  materials: [ /* ... */ ],
  shopProducts: [ /* 6 件商品,刻意跨 2 家店 + 自营 */ ],
  stores: [ /* 2 家店铺 */ ],
  shopOrders: [ /* ... */ ],
};

export function getPlaceholder(key) {
  return PLACEHOLDERS[key] ?? null;
}

新增一个模块的占位数据:在 PLACEHOLDERS 里加 key,然后在对应的 API 函数里接上降级逻辑。

export async function getCoupons() {
  if (DEMO_MODE) return demoRes('coupons');
  const res = await get('/coupons');
  if (isOffline(res)) {
    return { ok: true, data: { items: getPlaceholder('coupons') }, offline: true };
  }
  return res;
}

4.3 DEMO_MODE:官网演示专用

// utils/config.js
function detectDemoMode() {
  if (!IS_H5) return false;
  try {
    const href = window.location.href || '';
    const path = window.location.pathname || '';
    if (/[?&]demo=1/.test(href)) return true;
    return /^\/mp(\/|$)/.test(path);
  } catch {
    return false;
  }
}
export const DEMO_MODE = detectDemoMode();

为什么需要它? 官网把 H5 产物挂在 www.chaoyi.club/mp/ 下用 iframe 展示。那个域名没代理 /api,如果走真实请求会 401 或打到生产库。

DEMO_MODE 下所有 API 直接返回占位数据,保证演示永远可点可滑,也不污染生产数据。

配合 utils/auth.js:

export function isLoggedIn() {
  if (DEMO_MODE) return true;    // 演示模式恒为已登录
  return !!getToken();
}

export function logout() {
  if (DEMO_MODE) {
    uni.showToast({ title: '演示模式无需登录', icon: 'none' });
    return;                       // 不清态,也不跳登录页
  }
  uni.removeStorageSync(TOKEN_KEY);
  uni.removeStorageSync(AGENT_INFO_KEY);
  uni.reLaunch({ url: '/pages/login/index' });
}
⚠️ isLoggedIn() 在 DEMO_MODE 返回 true 是必须的 —— 否则工作台的 onLoad 会 reLaunch 到登录页,演示 iframe 里就是一片白。

五、国际化:7 语种

5.1 结构

// utils/i18n.js
export const LOCALES = [
  { code: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
  { code: 'zh-TW', label: '繁體中文(台灣)', flag: '🇨🇳' },
  { code: 'zh-HK', label: '繁體中文(香港)', flag: '🇭🇰' },
  { code: 'zh-MO', label: '繁體中文(澳門)', flag: '🇲🇴' },
  { code: 'ja',    label: '日本語', flag: '🇯🇵' },
  { code: 'en',    label: 'English (UK)', flag: '🇬🇧' },
  { code: 'en-US', label: 'English (US)', flag: '🇺🇸' },
];

const I18N = {
  'zh-CN': { 'app.title': '分销代理多端触达', 'tab.home': '工作台', /* ... */ },
  'zh-TW': { /* ... */ },
  // ... 7 份
};

// 商城词条单独一块,最后合并进主字典
const SHOP_I18N = { 'zh-CN': { /* ... */ }, /* 7 份 */ };
Object.keys(SHOP_I18N).forEach((loc) => {
  if (I18N[loc]) Object.assign(I18N[loc], SHOP_I18N[loc]);
  else I18N[loc] = SHOP_I18N[loc];
});

export function t(key, fallback) { /* ... */ }
export function getLocale() { /* ... */ }
export function setLocale(locale) { /* ... */ }

5.2 加词条

手工加 7 份容易漏,项目里用脚本批量注入:

// scripts/inject-dashboard-i18n.cjs
const ENTRIES = [
  {
    anchor: "    'dashboard.aiAssistant': 'AI助手',\n",
    inject:
      "    'dashboard.mallEntryTitle': 'C 端商城',\n" +
      "    'dashboard.mallEntryDesc': '浏览商品 · 一键代客下单 · 跨店自动拆单',\n",
  },
  {
    anchor: "    'dashboard.aiAssistant': 'AIアシスタント',\n",
    inject: /* 日文 */,
  },
  {
    anchor: "    'dashboard.aiAssistant': 'AI Assistant',\n",  // 英文两份共用
    inject: /* 英文 */,
  },
];

跑之前先确认:anchor 字符串在每个语言块里是唯一的,否则会注入到错误的位置。英文块有 en 和 en-US 两份,同一个 anchor 会被替换两次 —— 这正好是我们想要的。

5.3 页面里切换语言

import { t, setLocale, getLocale, LOCALES } from '../../utils/i18n.js';

export default {
  data() {
    return { currentLocale: getLocale(), locales: LOCALES };
  },
  methods: {
    t,
    changeLocale(code) {
      setLocale(code);
      this.currentLocale = code;
      this.loadData();      // 重新拉数据(后端也支持多语言时)
    },
  },
};

切换后如果界面没刷新,用 this.$forceUpdate() 强制重渲染。


六、tabBar:图标不用找设计师

5 个 tab:工作台 / 商城 / 素材 / 佣金 / 我的。

scripts/gen-tabbar-icons.cjs 是零依赖的 PNG 编码器(用 Node 内置 zlib),直接画出图标:

node scripts/gen-tabbar-icons.cjs
# 生成 10 个 81×81 PNG 到 src/static/tabbar/

配置:

{
  "tabBar": {
    "color": "#9ca3af",
    "selectedColor": "#2563eb",
    "borderStyle": "black",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "工作台",
        "iconPath": "static/tabbar/home.png",
        "selectedIconPath": "static/tabbar/home_on.png"
      },
      {
        "pagePath": "pages/shop/index",
        "text": "商城",
        "iconPath": "static/tabbar/shop.png",
        "selectedIconPath": "static/tabbar/shop_on.png"
      }
      // ...
    ]
  }
}
📌 tabBar 的 pagePath 必须也在 pages 数组里,且建议放前面。 tabBar 图标必须是本地静态文件,不能用网络图,也不用 base64。

七、编译与构建

npm install

npm run dev:h5          # H5 开发(默认 8081,/api 代理到 3000)
npm run build:h5        # 构建 H5 → dist/build/h5/
npm run dev:mp-weixin   # 微信小程序开发
npm run build:mp-weixin # 构建 → dist/build/mp-weixin/(用微信开发者工具打开)
npm run build:app       # 构建 App

7.1 H5 产物部署

构建产物在 dist/build/h5/,index.html 里的资源路径已带 /mp/ 前缀。

整个目录丢到 nginx 的 /var/www/www-site/mp/ 即可:

location ^~ /mp/ {
    alias /var/www/www-site/mp/;
    index index.html;
    try_files $uri $uri/ /mp/index.html;   # hash 路由回落
}
location = /mp { return 301 /mp/; }

manifest.json 里 H5 路由是 hash 模式,所以直接改 iframe 的 hash 就能跳页:

/mp/#/pages/shop/index
/mp/#/pages/shop/cart

7.2 本机构建的一个坑(Windows)

现象:

[safe-delete] 操作失败: ERROR .../uniapp/dist/build/h5/assets:
Error during a `trash` operation: Unknown { description: "Some operations were aborted" }
Build failed with errors.

原因:部分环境注入了 safe-delete 钩子,它接管了 shell 的 rm/mv 和 Node 的 fs.rmSync,把删除操作改成"移到回收站"。而 uni build 在构建前会清理 dist/,于是被拦截。

解法:fs.renameSync 不在拦截范围内。项目里的 scripts/clean-dist.cjs 用"重命名归档"绕过去:

// 把旧 dist 重命名到 .dist-cache/<时间戳>/,让 outDir 变成「不存在」
fs.renameSync(DIST, target);
node scripts/clean-dist.cjs && npm run build:h5
如果你在别的机器遇到同样报错,用同样的思路解决:别删,改名。

7.3 另一个坑:@rollup/pluginutils 报模块找不到

Cannot find module @rollup/pluginutils/dist/cjs/index.js

npm install 有时产出空的 dist/cjs/ 目录。重装即可:

rm -rf node_modules/@rollup/pluginutils
npm install @rollup/pluginutils@5.1.0 --no-save

八、小程序端 API 地址

小程序和 App 不能用相对路径 /api,必须直连后端域名:

// #ifndef H5
return 'http://localhost:3000/api';   // ← 上线前改成 https 公网地址
// #endif

改完还要在微信公众平台 → 开发 → 开发管理 → 服务器域名 → request 合法域名里加上你的后端域名,且必须是 https。


九、本篇 checklist

  • [ ] 新页面建在 src/pages/ 下
  • [ ] pages.json 注册了(路径不带 .vue、不带前导 /)
  • [ ] 跳转统一用 goto(),新增 tabBar 页时同步更新 nav.js 的 TAB_PAGES
  • [ ] 新增 API 时加了 DEMO_MODE 分支和离线占位数据
  • [ ] 文案走 t(),7 种语言都补了词条
  • [ ] 样式用 rpx + <style scoped>
  • [ ] 构建前跑了 node scripts/clean-dist.cjs
  • [ ] 小程序上线前把 BASE_URL 改成 https 并配好域名白名单

下一篇

06 合规护栏与二开禁区 —— 这个项目最硬的部分:15 条红线、代码冻结机制,以及二次开发时哪些地方碰不得、碰了会怎样。