This commit is contained in:
2026-08-26 20:50:58 +08:00
parent 258f5414d1
commit 88b52cff40
14 changed files with 488 additions and 90 deletions
+5
View File
@@ -0,0 +1,5 @@
VITE_API_BASE_URL=http://127.0.0.1:9000/api
VITE_H5_API_BASE_URL=/gateway-api
API_PROXY_TARGET=http://127.0.0.1:9000
# 国内 PDA 独立更新清单地址;仅 App-Plus 启动时请求。
VITE_PDA_UPDATE_URL=https://updates.example.com/pda/domestic/latest
+8 -2
View File
@@ -8,10 +8,13 @@
## 开发与发布边界
- 当前已有 uni-app Vue 3 静态原型和 `uni_modules/`,没有项目根级依赖清单或可核实的 HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置,但仍没有项目根级依赖清单或可复现的 npm/HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
- 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。
- 在 `package.json` 与锁文件正式补齐前,`vite.config.js` 只证明目标配置,不证明依赖可安装;维护者只能运行仓库当前可执行的 `node --test tests/*.test.mjs`,不得从海外 PDA 或全局缓存猜测国内 PDA 的 uni-app/Vite 版本。
- App API 根只读 `VITE_API_BASE_URL`;H5 固定优先使用 `VITE_H5_API_BASE_URL` 的同源 `/api` 并由 `vite.config.js` 将其代理到 `API_PROXY_TARGET`。环境地址不写入源码,切换构建环境后不得沿用登录缓存中的旧 API 地址。
- 国内 Android App 启动后仅在 `APP-PLUS` 调用 `utils/app-update.js`,通过独立 `VITE_PDA_UPDATE_URL` 检查更新;更新服务和下载地址必须为 HTTPS。APK 以整数 `versionCode` 判新,WGT 以 `versionName` 判新;强制更新不可跳过,WGT 安装后立即重启。国内 AppID、包名、签名、版本和更新通道不得复用海外 PDA。
## 作业流程原则
@@ -38,13 +41,16 @@
- 在真实目标 PDA 上验证扫描速度、连续扫描、断网阻断与恢复刷新、低电量、前后台切换和重复提交。
- 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。
- 当前原型没有可核实的 HBuilderX/Android 构建命令,不得声称打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。
- 修改在线更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发布 APK,不能只发布 WGT。
- 当前没有依赖清单和可核实的 npm/HBuilderX/Android 构建命令,不得仅凭 `vite.config.js` 声称 H5、App 打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。
## 页面与能力快速查询
| 目标 | 入口 | 继续查询 |
|---|---|---|
| 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 |
| API 环境与 H5 开发代理 | `config/runtime.js`、`.env.example`、`vite.config.js` | App `VITE_API_BASE_URL`;H5 同源 `VITE_H5_API_BASE_URL=/api`,代理上游 `API_PROXY_TARGET`;会话恢复不得覆盖当前构建地址 |
| Android APK/WGT 在线更新 | `App.vue`、`utils/app-update.js`、`.env.example`、`manifest.json` | `VITE_PDA_UPDATE_URL` 使用国内独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 |
| 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 |
| 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 |
| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 |
+9 -1
View File
@@ -1,5 +1,13 @@
<script>
export default {}
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
onLaunch() {
// #ifdef APP-PLUS
setTimeout(() => checkForAppUpdate(), 800)
// #endif
}
}
</script>
<style lang="scss">
+23
View File
@@ -70,3 +70,26 @@ node --test tests/*.test.mjs
- 锁码格式、长度和唯一性规则。
一期需要确认是否与管理后台头程订单及仓库任务同步上线。
## App 在线更新
Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独立更新通道,员工也可在“个人中心 → 检查更新”手动触发;H5 仅展示 App 操作提示,不执行更新。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
```json
{
"update": true,
"packageType": "apk",
"versionName": "0.0.2",
"versionCode": 2,
"downloadUrl": "https://updates.example.com/pda/domestic/0.0.2.apk",
"mandatory": false,
"releaseNotes": "本次更新内容"
}
```
`packageType` 只允许 `apk` 或 `wgt`。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
1. 国内 PDA 每次整包发布同时递增 `manifest.json` 的 `versionName` 与整数 `versionCode`,使用国内独立 AppID、包名和签名打包 APK。
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
+8 -1
View File
@@ -1,4 +1,11 @@
const configuredApiRoot = import.meta.env.VITE_API_BASE_URL || ''
let selectedApiRoot = configuredApiRoot || '/api'
export const API_ROOT = String(configuredApiRoot || '/api').replace(/\/$/, '')
// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。
// 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。
// #ifdef H5
selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api'
// #endif
export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '')
export const PLATFORM_API_ROOT = `${API_ROOT}/platform/v1`
+2
View File
@@ -18,3 +18,5 @@
<script type="module" src="/main.js"></script>
</body>
</html>
+93 -74
View File
@@ -1,76 +1,95 @@
{
"name" : "pda-uniapp",
"appid" : "__UNI__9BFFAF2",
"description" : "",
"versionName" : "0.0.1",
"versionCode" : 1,
"transformPx" : false,
"app-plus" : {
/* 5+App特有相关 */
"usingComponents" : true,
"nvueCompiler" : "uni-app",
"nvueStyleCompiler" : "uni-app",
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"modules" : {},
/* 模块配置 */
"distribute" : {
/* 应用发布信息 */
"android" : {
/* android打包配置 */
"permissions" : []
},
"ios" : {},
/* ios打包配置 */
"sdkConfigs" : {},
"icons" : {
"android" : {
"hdpi" : "static/app-icons/72x72.png",
"xhdpi" : "static/app-icons/96x96.png",
"xxhdpi" : "static/app-icons/144x144.png",
"xxxhdpi" : "static/app-icons/192x192.png"
},
"ios" : {
"appstore" : "static/app-icons/1024x1024.png",
"ipad" : {
"app" : "static/app-icons/76x76.png",
"app@2x" : "static/app-icons/152x152.png",
"notification" : "static/app-icons/20x20.png",
"notification@2x" : "static/app-icons/40x40.png",
"proapp@2x" : "static/app-icons/167x167.png",
"settings" : "static/app-icons/29x29.png",
"settings@2x" : "static/app-icons/58x58.png",
"spotlight" : "static/app-icons/40x40.png",
"spotlight@2x" : "static/app-icons/80x80.png"
},
"iphone" : {
"app@2x" : "static/app-icons/120x120.png",
"app@3x" : "static/app-icons/180x180.png",
"notification@2x" : "static/app-icons/40x40.png",
"notification@3x" : "static/app-icons/60x60.png",
"settings@2x" : "static/app-icons/58x58.png",
"settings@3x" : "static/app-icons/87x87.png",
"spotlight@2x" : "static/app-icons/80x80.png",
"spotlight@3x" : "static/app-icons/120x120.png"
}
}
}
}
},
/* SDK配置 */
"quickapp" : {},
/* 快应用特有相关 */
"mp-weixin" : {
/* 小程序特有相关 */
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"vueVersion" : "3"
"name": "柏悦物流 PDA",
"appid": "__UNI__9BFFAF2",
"description": "柏悦物流 PDA 作业端",
"versionName": "0.0.1",
"versionCode": 1,
"transformPx": false,
"app-plus": {
/* 5+App特有相关 */
"usingComponents": true,
"nvueCompiler": "uni-app",
"nvueStyleCompiler": "uni-app",
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {
"Barcode": {},
"Bluetooth": {},
"Camera": {},
"Geolocation": {},
"UIWebview": {}
},
/* 模块配置 */
"distribute": {
/* 应用发布信息 */
"android": {
/* android打包配置 */
"permissions": []
},
"ios": {
"dSYMs": false
},
/* ios打包配置 */
"sdkConfigs": {
"geolocation": {
"system": {
"__platform__": ["ios", "android"]
}
}
},
"icons": {
"android": {
"hdpi": "static/app-icons/72x72.png",
"xhdpi": "static/app-icons/96x96.png",
"xxhdpi": "static/app-icons/144x144.png",
"xxxhdpi": "static/app-icons/192x192.png"
},
"ios": {
"appstore": "static/app-icons/1024x1024.png",
"ipad": {
"app": "static/app-icons/76x76.png",
"app@2x": "static/app-icons/152x152.png",
"notification": "static/app-icons/20x20.png",
"notification@2x": "static/app-icons/40x40.png",
"proapp@2x": "static/app-icons/167x167.png",
"settings": "static/app-icons/29x29.png",
"settings@2x": "static/app-icons/58x58.png",
"spotlight": "static/app-icons/40x40.png",
"spotlight@2x": "static/app-icons/80x80.png"
},
"iphone": {
"app@2x": "static/app-icons/120x120.png",
"app@3x": "static/app-icons/180x180.png",
"notification@2x": "static/app-icons/40x40.png",
"notification@3x": "static/app-icons/60x60.png",
"settings@2x": "static/app-icons/58x58.png",
"settings@3x": "static/app-icons/87x87.png",
"spotlight@2x": "static/app-icons/80x80.png",
"spotlight@3x": "static/app-icons/120x120.png"
}
}
}
}
},
/* SDK配置 */
"quickapp": {},
/* 快应用特有相关 */
"mp-weixin": {
/* 小程序特有相关 */
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"vueVersion": "3",
"h5": {
"devServer": {
"port": 5175
}
}
}
+34 -3
View File
@@ -1,7 +1,38 @@
<template>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">0.0.1</text></view></view><button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view></view><button class="profile-action" @tap="checkUpdate">检查更新 <text>›</text></button><button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'; import PopupHeader from '@/components/PopupHeader.vue'; import { session, requireSession, signOut } from '@/stores/session.js'
export default { components: { PopupHeader, PortalHeader }, data() { return { user: session.currentUser, logoutOpen: false, toast: false } }, onShow() { if (!requireSession()) return; this.user = session.currentUser }, beforeUnmount() { clearTimeout(this._toastTimer) }, onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true }, methods: { openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) } } }
import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.vue'
import { session, requireSession, signOut } from '@/stores/session.js'
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
components: { PopupHeader, PortalHeader },
data() { return { user: session.currentUser, appVersion: '0.0.1', logoutOpen: false, toast: false } },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadAppVersion() },
beforeUnmount() { clearTimeout(this._toastTimer) },
onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true },
methods: {
loadAppVersion() {
// #ifdef APP-PLUS
this.appVersion = String(plus.runtime.version || this.appVersion)
// #endif
},
checkUpdate() {
// #ifdef APP-PLUS
checkForAppUpdate({ manual: true })
return
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 PDA App 中检查更新', icon: 'none' })
// #endif
},
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() },
closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() },
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
}
}
</script>
+14 -3
View File
@@ -5,7 +5,8 @@
<view class="task-toolbar">
<view class="task-heading">
<text class="task-title">待处理任务</text>
<text class="task-summary">{{ loading ? '正在同步' : `共 ${tasks.length} 个可处理任务` }}</text>
<text class="task-summary">{{ loading ? '正在同步' : `接口返回 ${rawTaskCount} 个,可处理 ${tasks.length} 个` }}</text>
<text class="task-environment">{{ apiEnvironment }}</text>
</view>
<button class="task-refresh" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中' : '刷新' }}</button>
</view>
@@ -40,7 +41,14 @@
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, tasks: [], loading: false, error: '', notice: '', online: true, toast: { visible: false, message: '', type: '' } } },
data() { return { user: session.currentUser, tasks: [], rawTaskCount: 0, loading: false, error: '', notice: '', online: true, toast: { visible: false, message: '', type: '' } } },
computed: {
apiEnvironment() {
const apiBase = String(session.apiBase || '')
const host = apiBase.match(/^https?:\/\/([^/]+)/i)?.[1]
return `当前接口:${host || apiBase || '未配置'} · 账号:${session.currentUser?.name || session.currentUser?.id || '未知'}`
}
},
onLoad() { if (!requireSession()) return; this._stopNetwork = watchNetwork((value) => { this.online = value }) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) },
@@ -61,12 +69,14 @@
isTaskReady(task) { return Boolean(task.recordStatus === 'ACTIVE' && !task.mergedIntoContainerBookingId && task.taskId && task.loadingPlanId && task.containerBookingId && task.cabinetNo && task.currentContainerNo && task.warehouseId && task.version !== null && task.version !== undefined && Number.isInteger(Number(task.version)) && (task.workflowStatus === 'AWAITING_REVIEW' || (task.assignmentVersion !== null && task.assignmentVersion !== undefined && Number.isInteger(Number(task.assignmentVersion)))) && ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'].includes(task.workflowStatus)) },
async loadTasks() {
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return }
this.loading = true; this.error = ''; this.notice = ''
this.loading = true; this.error = ''; this.notice = ''; this.rawTaskCount = 0
try {
const result = await listLoadingOrders({ page: 1, size: 50 })
const records = result.records || []
this.rawTaskCount = records.length
records.filter((task) => this.isMergedTask(task)).forEach((task) => this.clearMergedTaskDraft(task))
this.tasks = records.filter((task) => this.isTaskReady(task))
try { console.info('[PDA] pending tasks', { apiBase: session.apiBase, operatorId: session.currentUser?.id, received: records.length, visible: this.tasks.length }) } catch (_) {}
if (records.length > this.tasks.length) this.notice = '部分任务尚未完成服务端就绪校验,本机已隐藏,请稍后刷新'
} catch (error) {
this.error = error.network ? '当前网络不可用,请联网后刷新' : (error.message || '任务加载失败')
@@ -87,6 +97,7 @@
.task-heading { display: flex; min-width: 0; flex-direction: column; }
.task-title { color: #12233f; font-size: 18px; font-weight: 700; line-height: 25px; }
.task-summary { margin-top: 2px; color: #667085; font-size: 11px; line-height: 16px; }
.task-environment { margin-top: 1px; color: #98a2b3; font-size: 10px; line-height: 15px; }
.task-refresh { flex-shrink: 0; margin: 0; min-width: 76px; height: 44px; border: 0; border-radius: 9px; background: #1a73e8; color: #fff; font-size: 14px; line-height: 44px; }
.task-refresh[disabled] { opacity: .6; }
.task-error, .task-state { padding: 20px 14px; border-radius: 10px; background: #fff; color: #667085; font-size: 13px; line-height: 20px; text-align: center; }
+5 -4
View File
@@ -34,8 +34,7 @@ function persistSession() {
currentUser: session.currentUser,
token: session.token,
refreshToken: session.refreshToken,
tenantId: session.tenantId,
apiBase: session.apiBase
tenantId: session.tenantId
})
}
@@ -44,7 +43,9 @@ export function signIn(account, warehouse, options = {}) {
session.token = options.token || account.token || ''
session.refreshToken = options.refreshToken || account.refreshToken || ''
session.tenantId = options.tenantId || account.tenantId || ''
session.apiBase = options.apiBase || account.apiBase || PLATFORM_API_ROOT
// The endpoint belongs to the installed build, not to a persisted login.
// Otherwise changing environments leaves existing devices on the old API.
session.apiBase = PLATFORM_API_ROOT
persistSession()
return session.currentUser
}
@@ -73,7 +74,7 @@ export function restoreSession() {
try {
const claims = decodeAccessToken(saved.token)
if (claims.exp && claims.exp * 1000 <= Date.now()) return false
Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT })
Object.assign(session, saved, { apiBase: PLATFORM_API_ROOT })
return true
} catch (_) {
return false
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('App-Plus checks the independent update channel after launch', async () => {
const [app, updater, env] = await Promise.all([
read('../App.vue'),
read('../utils/app-update.js'),
read('../.env.example')
])
assert.match(app, /#ifdef APP-PLUS[\s\S]*?checkForAppUpdate\(\)/)
assert.match(env, /VITE_PDA_UPDATE_URL=https:\/\//)
assert.match(updater, /plus\.runtime\.getProperty/)
assert.match(updater, /appid:[\s\S]*?platform: 'android'[\s\S]*?appVersion:[\s\S]*?appVersionCode:[\s\S]*?wgtVersion:/)
})
test('online update supports mandatory APK and wgt installation flows', async () => {
const updater = await read('../utils/app-update.js')
assert.match(updater, /\['apk', 'wgt'\]\.includes\(packageType\)/)
assert.match(updater, /update\.versionCode > runtime\.appVersionCode/)
assert.match(updater, /compareVersions\(update\.versionName, runtime\.wgtVersion\) > 0/)
assert.match(updater, /更新包必须使用 HTTPS 地址/)
assert.match(updater, /showCancel: !update\.mandatory/)
assert.match(updater, /uni\.downloadFile\([\s\S]*?onProgressUpdate/)
assert.match(updater, /plus\.runtime\.install/)
assert.match(updater, /packageType === 'wgt'[\s\S]*?plus\.runtime\.restart/)
assert.match(updater, /if \(retry\) await runUpdate\(update\)/)
})
test('profile exposes manual App update checking and the runtime version', async () => {
const profile = await read('../pages/profile/profile.vue')
assert.match(profile, /应用版本[\s\S]*?\{\{ appVersion \}\}/)
assert.match(profile, /@tap="checkUpdate">检查更新/)
assert.match(profile, /plus\.runtime\.version/)
assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/)
assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/)
})
+10 -2
View File
@@ -23,8 +23,8 @@ test('application uses the Vue 3 uni-app entry and no unused Android permissions
})
test('login uses the real auth endpoint and relaunches to the modular workbench', async () => {
const [login, auth, session, runtime] = await Promise.all([
read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js')
const [login, auth, session, runtime, vite, manifest] = await Promise.all([
read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js'), read('../vite.config.js'), read('../manifest.json')
])
for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text))
assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/)
@@ -34,7 +34,15 @@ test('login uses the real auth endpoint and relaunches to the modular workbench'
assert.match(session, /pda-auth-session/)
assert.match(session, /claims\.app_code !== 'PLATFORM'/)
assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL \|\| '\/gateway-api'/)
assert.doesNotMatch(runtime, /typeof\s+import\.meta/)
assert.match(vite, /API_PROXY_TARGET/)
assert.match(vite, /process\.env\.UNI_INPUT_DIR \|\| process\.cwd\(\)/)
assert.match(vite, /'\/gateway-api'[\s\S]*?target: proxyTarget[\s\S]*?changeOrigin: true[\s\S]*?replace\(\/\^\\\/gateway-api\/[\s\S]*?'\/api'/)
assert.match(vite, /removeHeader\('origin'\)/)
assert.match(vite, /removeHeader\('referer'\)/)
assert.match(vite, /proxy\.on\('error'/)
assert.doesNotMatch(manifest, /"proxy"\s*:|pathRewrite|disableHostCheck/)
assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/)
})
+195
View File
@@ -0,0 +1,195 @@
const updateEndpoint = String(import.meta.env.VITE_PDA_UPDATE_URL || '').trim()
let checking = false
function request(options) {
return new Promise((resolve, reject) => {
uni.request({
...options,
success: resolve,
fail: reject
})
})
}
function getRuntimeInfo() {
return new Promise((resolve, reject) => {
if (typeof plus === 'undefined' || !plus.runtime) {
reject(new Error('当前环境不支持 App 在线更新'))
return
}
plus.runtime.getProperty(plus.runtime.appid, (properties) => {
resolve({
appid: plus.runtime.appid,
appVersion: String(plus.runtime.version || ''),
appVersionCode: Number(plus.runtime.versionCode || 0),
wgtVersion: String(properties?.version || plus.runtime.version || '')
})
})
})
}
function normalizeUpdate(payload) {
const value = payload && typeof payload === 'object' ? payload : {}
if (value.update !== true) return null
const packageType = String(value.packageType || '').toLowerCase()
const downloadUrl = String(value.downloadUrl || '').trim()
const versionName = String(value.versionName || '').trim()
if (!['apk', 'wgt'].includes(packageType) || !downloadUrl || !/^\d+(\.\d+)+$/.test(versionName)) {
throw new Error('更新服务返回了无效的版本信息')
}
if (!/^https:\/\//i.test(downloadUrl)) throw new Error('更新包必须使用 HTTPS 地址')
return {
packageType,
downloadUrl,
versionName,
versionCode: Number(value.versionCode || 0),
mandatory: value.mandatory === true,
releaseNotes: String(value.releaseNotes || '').trim()
}
}
function compareVersions(left, right) {
const leftParts = String(left).split('.').map(Number)
const rightParts = String(right).split('.').map(Number)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] || 0) - (rightParts[index] || 0)
if (difference !== 0) return difference
}
return 0
}
function isNewerUpdate(update, runtime) {
if (update.packageType === 'apk') {
return Number.isInteger(update.versionCode) && update.versionCode > runtime.appVersionCode
}
return compareVersions(update.versionName, runtime.wgtVersion) > 0
}
function confirmUpdate(update) {
const notes = update.releaseNotes || '修复已知问题并提升作业稳定性。'
return new Promise((resolve) => {
uni.showModal({
title: `${update.mandatory ? '必须' : '发现'}更新 ${update.versionName}`,
content: notes,
confirmText: '立即更新',
cancelText: '稍后再说',
showCancel: !update.mandatory,
success: ({ confirm }) => resolve(Boolean(confirm)),
fail: () => resolve(false)
})
})
}
function downloadUpdate(update) {
return new Promise((resolve, reject) => {
uni.showLoading({ title: '正在下载 0%', mask: true })
const task = uni.downloadFile({
url: update.downloadUrl,
success: (result) => {
if (result.statusCode === 200 && result.tempFilePath) {
resolve(result.tempFilePath)
return
}
reject(new Error(`更新包下载失败(${result.statusCode || '未知状态'})`))
},
fail: () => reject(new Error('更新包下载失败,请检查网络后重试')),
complete: () => uni.hideLoading()
})
task.onProgressUpdate?.(({ progress }) => {
uni.showLoading({ title: `正在下载 ${progress}%`, mask: true })
})
})
}
function installUpdate(filePath, update) {
return new Promise((resolve, reject) => {
plus.runtime.install(
filePath,
{ force: false },
resolve,
() => reject(new Error('更新包安装失败,请重新下载'))
)
})
}
function showFailure(error, mandatory) {
const message = error?.message || '检查更新失败,请稍后重试'
if (!mandatory) {
uni.showToast({ title: message, icon: 'none', duration: 3000 })
return Promise.resolve(false)
}
return new Promise((resolve) => {
uni.showModal({
title: '更新失败',
content: message,
confirmText: '重新尝试',
showCancel: false,
complete: () => resolve(true)
})
})
}
async function runUpdate(update) {
const accepted = await confirmUpdate(update)
if (!accepted) return
try {
const filePath = await downloadUpdate(update)
await installUpdate(filePath, update)
if (update.packageType === 'wgt') {
uni.showModal({
title: '更新完成',
content: '应用将立即重启以启用新版本。',
showCancel: false,
complete: () => plus.runtime.restart()
})
}
} catch (error) {
const retry = await showFailure(error, update.mandatory)
if (retry) await runUpdate(update)
}
}
export async function checkForAppUpdate({ manual = false } = {}) {
if (checking) return
if (!updateEndpoint) {
if (manual) uni.showToast({ title: '未配置更新服务', icon: 'none' })
return
}
checking = true
try {
if (!/^https:\/\//i.test(updateEndpoint)) throw new Error('更新服务必须使用 HTTPS 地址')
const runtime = await getRuntimeInfo()
const response = await request({
url: updateEndpoint,
method: 'GET',
data: {
appid: runtime.appid,
platform: 'android',
appVersion: runtime.appVersion,
appVersionCode: runtime.appVersionCode,
wgtVersion: runtime.wgtVersion
}
})
if (response.statusCode !== 200) throw new Error('更新服务暂时不可用')
const update = normalizeUpdate(response.data?.data ?? response.data)
if (update && isNewerUpdate(update, runtime)) {
await runUpdate(update)
} else if (manual) {
uni.showToast({ title: '当前已是最新版本', icon: 'none' })
}
} catch (error) {
if (manual) await showFailure(error, false)
} finally {
checking = false
}
}
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, loadEnv } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
function loggedProxy(options) {
return {
...options,
configure(proxy) {
proxy.on('proxyReq', (proxyRequest, request) => {
proxyRequest.removeHeader('origin')
proxyRequest.removeHeader('referer')
console.info('[Domestic PDA H5 proxy]', `${request.method} ${request.url}`, '->', `${options.target}${proxyRequest.path}`)
})
proxy.on('proxyRes', (response, request) => {
console.info('[Domestic PDA H5 proxy response]', `${request.method} ${request.url}`, response.statusCode)
})
proxy.on('error', (error, request) => {
console.error('[Domestic PDA H5 proxy error]', `${request.method} ${request.url}`, error.message)
})
}
}
}
export default defineConfig(({ mode }) => {
const projectRoot = process.env.UNI_INPUT_DIR || process.cwd()
const env = loadEnv(mode, projectRoot, '')
const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
return {
plugins: [uni()],
server: {
proxy: {
'/gateway-api': loggedProxy({
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/gateway-api/, '/api')
})
}
}
}
})