写在前面:一句报错让我卡了两天
在 Windows 上执行 npm run build:h5,构建直接挂了:
[safe-delete] 操作失败: ERROR .../uniapp/dist/build/h5/assets:
Error during a `trash` operation: Unknown { description: "Some operations were aborted" }
Build failed with errors.
第一反应是缓存问题。清了 node_modules,重装,无效。第二反应是版本问题,降级了 vite,无效。查了两天 Stack Overflow,最后发现问题根本不在 uni-app。
环境里有个钩子接管了文件删除,把 rm / mv 和 Node 的 fs.rmSync 都改成了"移到回收站"。而 uni build 在构建前会清理 dist/,于是被拦下了。
最终解法只有一行——不删,改名:
fs.renameSync(DIST, target);
这篇把整个 UniApp 二开过程中踩的坑都整理一遍,包括这个构建问题、依赖版本的坑、navigateTo 静默失败、7 语言 i18n 怎么维护,以及 tabBar 图标怎么不靠设计师。
一、目录结构:先确认你的 uni-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
框架是 uni-app (Vue 3) + Vite,一套代码编译 H5 / 微信小程序 / App / 企微侧边栏四个端。
二、依赖版本:别写 ^3.0.0
{
"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/* 的版本必须完全一致。
最后这条是我踩过的另一个坑:一开始我把 @dcloudio/uni-app 和 @dcloudio/uni-h5 写成了不同版本(都是 3.x 但 alpha 号不同),npm install 能过,编译时报各种诡异的模块找不到。@dcloudio/* 是一套联动的构建工具链,版本不齐会导致它们之间的内部 API 对不上。
对比一下 vue 和 vite 用 ^ 就没问题——因为它们有 stable 版本。
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/' : '/',
});
这行条件判断很重要。 H5 产物是挂在子路径 /mp/ 下的,如果 base 写死成 /,构建出来的资源引用全是根路径,部署到 /mp/ 目录之后 CSS、JS、图标全 404。非 H5 端则必须用 /,因为小程序不认子路径前缀。
三、加一个页面:三步
步骤 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() {
// 下拉刷新:数据回来后必须调 stopPullDownRefresh,否则菊花一直转
this.loadData().then(() => uni.stopPullDownRefresh());
},
methods: {
t, // ← 必须挂到 methods,模板才能用
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 会自动换算成对应设备的物理像素。用px在不同机型上会大小不一。 t和工具函数要在methods里挂出来才能在模板用。这是 Vue 2 风格的 Options API 的规则,我第一次写的时候以为import进来就能直接用,模板里报t is not defined。<style scoped>防止样式污染。
步骤 2:在 pages.json 注册
{
"pages": [
// ... 已有页面
{
"path": "pages/shop/coupon",
"style": {
"navigationBarTitleText": "我的优惠券",
"enablePullDownRefresh": true
}
}
]
}
注意:路径不带 .vue 后缀,且不以 / 开头。
style 常用字段:
| 字段 | 说明 |
|---|---|
navigationBarTitleText | 导航栏标题 |
navigationStyle | custom = 隐藏原生导航栏(自绘时用) |
enablePullDownRefresh | 开启下拉刷新(配合 onPullDownRefresh) |
第三个字段和页面代码里的 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 把这个坑封掉了:
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(/^\//, ''); // 去掉前导 / 和 query
try {
if (TAB_PAGES.has(path)) {
// tabBar 页必须用 switchTab;fail 兜底退回 navigateTo
uni.switchTab({ url, fail: () => uni.navigateTo({ url }) });
} else {
uni.navigateTo({ url });
}
return true;
} catch (e) {
console.warn('[nav] goto failed:', url, e);
return false;
}
}
三个细节:
- 先规整路径:
split('?')去掉参数、replace(/^\//, '')去掉前导斜杠,再和集合比对。否则goto('/pages/shop/index?id=1')匹配不上,会误判成普通页面。 switchTab带fail兜底:万一 tabBar 页被移除了配置,退回navigateTo,至少不会静默失败。- catch + 警告日志:uni 的跳转 API 部分错误不抛异常,catch 只是兜底,但有日志就能查。
加了新 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 的条件编译,构建时按平台剔除代码。写前端同路径,请求当前域名 + /api,由 nginx 反代到后端,天然没有跨域问题。小程序和 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;
}
shopProducts 那条注释有点讲究:刻意构造成跨 2 家店 + 自营。这样演示数据本身就覆盖了跨店铺拆单的场景,点一下购物车就能看到分组效果,不用专门造数据。
新增一个模块的占位数据:在 PLACEHOLDERS 里加 key,然后在对应的 API 函数里接上降级逻辑:
export async function getCoupons() {
if (DEMO_MODE) return demoRes('coupons'); // 演示模式直接返回占位
const res = await get('/coupons');
if (isOffline(res)) {
// 网络不通 → 返回占位数据,但标记 offline:true
return { ok: true, data: { items: getPlaceholder('coupons') }, offline: true };
}
return res;
}
返回 ok: true 配 offline: true 而不是 ok: false,是为了让页面的正常渲染逻辑不用改——页面只认 ok 和 data.items,降级数据走同一条路径。offline 标记留着以后要做提示用(比如顶栏显示"当前为离线数据")。
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; // URL 带 demo=1
return /^\/mp(\/|$)/.test(path); // 或路径就是 /mp/
} catch {
return false;
}
}
export const DEMO_MODE = detectDemoMode();
为什么需要它? H5 产物是挂在静态站子路径下、用 iframe 嵌入展示的。那个路径没有反代 /api,如果走真实请求会 401,甚至打到生产库。
DEMO_MODE 下所有 API 直接返回占位数据,保证演示永远可点可滑,也不污染生产数据。
三重保护是必要的:if (!IS_H5) 保证小程序端不会误判;try/catch 兜住 SSR 或受限环境下的 window 访问;两个条件都能独立触发,调试时直接 ?demo=1 最省事。
配合 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 里就是一片白。
这个坑排查起来很费劲:表现为"打开演示链接一片空白",控制台无报错,会怀疑是路由配置、CORS、或者 iframe 策略问题。实际原因是一个 if 少了一行。
五、国际化: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) { /* ... */ }
商城词条单独拆一块再合并,是因为商城是后加的模块,词条量不小。放一起会让字典文件长到没法维护——而且合并逻辑里的 else I18N[loc] = SHOP_I18N[loc] 保证了即使某语言在主字典里不存在,也不会丢词条。
5.2 加词条:用脚本,别手工
手工加 7 份很容易漏,而且漏了之后表现是"切到那个语言显示 key 名",不报错,很难发现。项目里用脚本批量注入:
// 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 会被替换两次——这正好是我们想要的,省得为两份几乎一样的英文各写一个 entry。
这个"锚点注入"的模式看起来土,但比引入 i18n 管理工具轻得多。7 种语言、条目结构完全一致的情况下,脚本方案的可靠性反而更高——因为它是幂等的,重复跑不会出错。
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/
这个脚本手写 PNG 编码有点反直觉,但考虑到需求就是"10 个简单几何图标",比装一个图像处理库轻。zlib 做 deflate 压缩,81×81 的简单图标压出来只有几 KB。
配置:
{
"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。这个限制在小程序端尤其严格——网络图片要配置域名白名单,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/ 前缀(这就是 §2 里 base 那行条件判断的作用)。
整个目录丢到 nginx 的 /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/; }
那行 try_files 的回落很关键。manifest.json 里 H5 路由是 hash 模式(#/pages/shop/cart),所以路径永远只有 /mp/index.html 一个真实文件。这条规则保证任何不存在的路径都回落到 index.html,否则用户直接粘贴一个深层链接的 URL 就会 404。
改 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
这里的思路值得单独说:拦截规则是按 API 名字匹配的,而不是按语义。 所以 fs.rmSync 会被拦,fs.renameSync 不会。想清楚了这一点,解法就是自然的了——目标不是"删除",而是"让输出目录不存在",改名同样能满足这个目标。
而且改名比删除更安全:旧产物还在 .dist-cache/<时间戳>/ 里,构建成功了可以回退,构建失败了可以对比排查。删除则是什么都不剩。
如果你在别的机器遇到同样报错,用同样的思路解决:别删,改名。
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
--no-save 是因为这只是修复当前环境的构建,不该改 package.json——这个包是 vite 的传递依赖,不该出现在直接依赖里。
八、小程序端的 API 地址
小程序和 App 不能用相对路径 /api,必须直连后端域名:
// #ifndef H5
return 'http://localhost:3000/api'; // ← 上线前改成 https 公网地址
// #endif
改完还要在微信公众平台 → 开发 → 开发管理 → 服务器域名 → request 合法域名里加上你的后端域名,且必须是 https。
"必须 https"是平台的硬性要求,不是建议。小程序发出去的请求如果检测到是 http,会被直接拦截,且这个拦截在开发工具里不一定复现,真机上才会暴露。所以本地开发用 http://localhost 没问题(开发者工具允许),但打包上传前一定要改。
九、加新功能前的自查清单
- 新页面建在
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 并配好域名白名单
小结
这次 UniApp 二开下来,最值钱的经验不是某个 API 怎么用,而是几个排查思路:
- 报错信息里的话要先读完。 「trash 操作被中止」直接指明了问题跟"回收站"有关,不是一个随机的构建错误。
- 静默失败最费时间。
navigateTo跳 tabBar 页不报错不跳转,排查成本是普通 bug 的好几倍。所以像goto()这种封装,宁可多写几行,也要保证任何路径都有明确的反馈。 - 遇到被拦截的底层操作,换语义等价的实现。
rmSync不行就renameSync,"让目录不存在"的目的一样能达到。 - 重复性劳动交给脚本。 7 份 i18n、10 个 tabBar 图标,手工维护的错误率远高于脚本。
最后一点补充:@dcloudio/* 的版本必须严格一致这一条,是这个生态特有的约束。alpha 版本没有 stable 可依赖,就意味着没有 semver 的保护网,你必须自己当那个锁版本的人。
代码已开源,Apache 2.0 协议,可自取:atomgit.com/pangzi_zi/d…