提交
This commit is contained in:
@@ -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,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`;合并任务和缺少柜号/仓库/版本的任务不可进入 |
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -18,3 +18,5 @@
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
+93
-74
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 中检查更新/)
|
||||
})
|
||||
@@ -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' \}\)/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user