分销代理多端触达系统 · 二次开发教程(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报错。 必须用vuedist-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 | 导航栏标题 |
navigationStyle | custom = 隐藏原生导航栏(自绘时用) |
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 条红线、代码冻结机制,以及二次开发时哪些地方碰不得、碰了会怎样。