From 51222f608aed6a2e8a654aeac4fdbafb9cf22a0b Mon Sep 17 00:00:00 2001 From: xuan <1359774872@qq.com> Date: Wed, 14 Oct 2026 09:52:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=A1=8C=E4=B8=8A=E4=BC=A0PDA=E5=AE=89=E8=A3=85=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 +++++- package.json | 1 + scripts/upload-apk.js | 286 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 scripts/upload-apk.js diff --git a/README.md b/README.md index 7379831..7139aec 100644 --- a/README.md +++ b/README.md @@ -100,4 +100,38 @@ pack.json内容如下:详情可访问 [CLI uni-app发行 - Android/iOS云打 } ``` ### 云服务器走CICD 打包: -需要下载 [Linux HBuilderX Cli](https://ask.dcloud.net.cn/article/41469) +需要下载 [Linux HBuilderX Cli](https://ask.dcloud.net.cn/article/41469) + +### 上传 PDA 安装包 + +云打包得到 APK 后,用命令行上传,等价于 ERP「开发者资源上传」里的安装包上传。登录和签名走本分支接口 `https://hcscmtest.zzfzyc.com/hcscm`。 + +先把 `src/manifest.json` 的 `versionName`、`versionCode` 改成新版本,再打包。安装包里的版本号必须和上传文件名一致,否则 PDA 会反复提示升级。 + +``` +npm run upload:apk -- --file D:\path\你的.apk --target test +npm run upload:apk -- --file D:\path\你的.apk --target prod +``` + +`npm` 后面的 `--` 只是分隔符,用来把 `--file`、`--target` 传给脚本。也可以不经过 npm: + +``` +node scripts/upload-apk.js --file D:\path\你的.apk --target test +``` + +运行后输入 ERP 手机号和密码获取 token。账号下有多个账套时,再按序号选一次。 + +`--target` 只决定 APK 放到哪套 CDN,没有别的差别。生产环境的 PDA 也可以从测试 CDN 下载并安装。 + +| 参数 | 说明 | +|---|---| +| `--target test` | 测试 CDN:`https://testhcscm.cdn.zzfzyc.com` | +| `--target prod` | 正式 CDN:`https://hcscm.cdn.zzfzyc.com` | +| `--mode` | 默认 `zhoushang`。可选 `zhoushang`、`zhisheng`、`mes`、`cjt` | +| `--version` | 可选。不传则用 `src/manifest.json` 的 `versionName` | + +场景值取对应 `.env.{mode}` 里的 `VUE_APP_UPGRADE_NAME`。保存路径为 `/version/{场景值}/{版本号}.apk`。本分支默认场景是 `.env.zhoushang` 的 `erp_pda_zhoushang`,上传成功后会打印完整下载地址,例如: + +``` +https://testhcscm.cdn.zzfzyc.com/version/erp_pda_zhoushang/1.1.9.apk +``` diff --git a/package.json b/package.json index f4d5106..1823fab 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "dev:quickapp-webview-huawei": "cross-env NODE_ENV=development UNI_PLATFORM=quickapp-webview-huawei vue-cli-service uni-build --watch", "dev:quickapp-webview-union": "cross-env NODE_ENV=development UNI_PLATFORM=quickapp-webview-union vue-cli-service uni-build --watch", "info": "node node_modules/@dcloudio/vue-cli-plugin-uni/commands/info.js", + "upload:apk": "node scripts/upload-apk.js", "serve:quickapp-native": "node node_modules/@dcloudio/uni-quickapp-native/bin/serve.js", "test:android": "cross-env UNI_PLATFORM=app-plus UNI_OS_NAME=android jest -i", "test:h5": "cross-env UNI_PLATFORM=h5 jest -i", diff --git a/scripts/upload-apk.js b/scripts/upload-apk.js new file mode 100644 index 0000000..1d973a4 --- /dev/null +++ b/scripts/upload-apk.js @@ -0,0 +1,286 @@ +/** + * 上传已打包的 PDA APK,等价于 ERP「开发者资源上传」里的安装包上传。 + * + * 用法: + * npm run upload:apk -- --file D:\path\app.apk --target test + * npm run upload:apk -- --file D:\path\app.apk --target prod + * npm run upload:apk -- --file D:\path\app.apk --target test --mode zhisheng + * + * --target 只决定 APK 放到哪套 CDN: + * test 测试 CDN + * prod 正式 CDN + * 两者没有别的差别。生产环境的 PDA 也可以从测试 CDN 下载并安装。 + * + * 版本号取 src/manifest.json 的 versionName。 + * 场景值取对应 .env 的 VUE_APP_UPGRADE_NAME,拼成 /version/{upgradeName}/{version}.apk。 + * 运行时输入 ERP 手机号和密码获取 token。多个账套时再选择一次。 + */ +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const ROOT = path.resolve(__dirname, '..'); +const API_BASE = 'https://hcscmtest.zzfzyc.com/hcscm'; +const UPYUN_API = 'https://v0.api.upyun.com'; +const CDN_HOST = { + test: 'https://testhcscm.cdn.zzfzyc.com', + prod: 'https://hcscm.cdn.zzfzyc.com', +}; + +const MODE_ENV = { + zhoushang: '.env.zhoushang', + zhisheng: '.env.zhisheng', + mes: '.env.mes', + cjt: '.env.cjt', +}; + +function parseArgs(argv) { + const args = { mode: 'zhoushang' }; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + const value = argv[i + 1]; + if (key === '--file' || key === '--target' || key === '--mode' || key === '--version') { + args[key.slice(2)] = value; + i += 1; + } + } + return args; +} + +function readEnvValue(filePath, name) { + const text = fs.readFileSync(filePath, 'utf8'); + const line = text.split(/\r?\n/).find((item) => item.trim().startsWith(`${name}`)); + if (!line) return ''; + const raw = line.slice(line.indexOf('=') + 1).trim(); + return raw.replace(/^['"]|['"]$/g, ''); +} + +function readManifestVersion() { + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/manifest.json'), 'utf8')); + return String(manifest.versionName || '').trim(); +} + +function ask(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(String(answer || '').trim()); + }); + }); +} + +function askPassword(question) { + return new Promise((resolve) => { + const stdin = process.stdin; + const stdout = process.stdout; + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { + ask(question).then(resolve); + return; + } + stdout.write(question); + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + let value = ''; + const onData = (char) => { + switch (char) { + case '\n': + case '\r': + case '\u0004': + stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener('data', onData); + stdout.write('\n'); + resolve(value); + break; + case '\u0003': + process.exit(); + break; + case '\u007f': + case '\b': + value = value.slice(0, -1); + break; + default: + value += char; + break; + } + }; + stdin.on('data', onData); + }); +} + +async function apiJson(apiBase, urlPath, { method = 'GET', token, body } = {}) { + const headers = { Platform: '1' }; + if (token) headers.Authorization = token; + if (body) headers['Content-Type'] = 'application/json'; + const res = await fetch(`${apiBase}${urlPath}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) { + throw new Error(data.msg || `请求失败(HTTP ${res.status})`); + } + return data.data || {}; +} + +async function login(apiBase) { + const phone = await ask('手机号: '); + const password = await askPassword('密码: '); + if (!phone || !password) { + throw new Error('请输入手机号和密码'); + } + console.log('正在登录...'); + const first = await apiJson(apiBase, '/admin/v1/login', { + method: 'POST', + body: { phone, password }, + }); + const tempToken = first.token; + if (!tempToken) throw new Error('登录未返回 token'); + + const dropdown = await apiJson(apiBase, '/admin/v1/tenantManagement/dropdownList', { + token: tempToken, + }); + const tenants = dropdown.list || []; + if (!tenants.length) return tempToken; + + let tenant = tenants[0]; + if (tenants.length > 1) { + console.log('请选择账套:'); + tenants.forEach((item, index) => { + const name = item.company_name || item.database_name || item.tenant_contacts || item.tenant_management_id; + console.log(` ${index + 1}. ${name}`); + }); + const picked = Number(await ask('序号: ')); + tenant = tenants[picked - 1]; + if (!tenant) throw new Error('账套序号无效'); + } + + const second = await apiJson(apiBase, '/admin/v1/login', { + method: 'POST', + token: tempToken, + body: { + token: tempToken, + tenant_management_id: tenant.tenant_management_id, + }, + }); + if (!second.token) throw new Error('选择账套后未返回 token'); + return second.token; +} + +async function getCdnSign({ apiBase, token, saveKey, status }) { + const url = new URL(`${apiBase}/admin/v1/cdn/token`); + url.searchParams.set('method', 'post'); + url.searchParams.set('save_key', saveKey); + url.searchParams.set('status', String(status)); + const res = await fetch(url, { + headers: { + Authorization: token, + Platform: '1', + }, + }); + const body = await res.json(); + if (!res.ok || body.code !== 0) { + throw new Error(body.msg || `获取签名失败(HTTP ${res.status})`); + } + return body.data || {}; +} + +async function uploadToUpyun({ bucket, authorization, policy, filePath, fileName }) { + const buf = fs.readFileSync(filePath); + const form = new FormData(); + form.append('authorization', authorization); + form.append('policy', policy); + form.append('file', new Blob([buf], { type: 'application/vnd.android.package-archive' }), fileName); + const res = await fetch(`${UPYUN_API}/${bucket}`, { + method: 'POST', + body: form, + }); + const text = await res.text(); + let body = {}; + try { + body = JSON.parse(text); + } catch (e) { + body = { raw: text }; + } + if (!res.ok || (body.code != null && Number(body.code) !== 200)) { + throw new Error(body.message || body.msg || body.raw || `上传失败(HTTP ${res.status})`); + } + return body; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.file || !args.target) { + console.error('用法: npm run upload:apk -- --file --target test|prod [--mode zhoushang]'); + console.error('--target 只切换 APK 的 CDN:test 测试 CDN,prod 正式 CDN。生产环境也可以从测试 CDN 下载安装。'); + process.exit(1); + } + if (args.target !== 'test' && args.target !== 'prod') { + console.error('--target 只能是 test 或 prod。两者只是 APK 的 CDN 环境不同,生产环境也可以从测试 CDN 下载安装。'); + process.exit(1); + } + const envFile = MODE_ENV[args.mode]; + if (!envFile) { + console.error(`不支持的 --mode:${args.mode},可选 zhoushang、zhisheng、mes、cjt`); + process.exit(1); + } + const apkPath = path.resolve(args.file); + if (!fs.existsSync(apkPath)) { + console.error(`找不到 APK:${apkPath}`); + process.exit(1); + } + + const version = args.version || readManifestVersion(); + if (!version) { + console.error('manifest.json 里没有 versionName'); + process.exit(1); + } + const upgradeName = readEnvValue(path.join(ROOT, envFile), 'VUE_APP_UPGRADE_NAME'); + if (!upgradeName) { + console.error(`${envFile} 里没有 VUE_APP_UPGRADE_NAME`); + process.exit(1); + } + + const status = args.target === 'prod' ? 2 : 1; + const fileName = `${version}.apk`; + const saveKey = `/version/${upgradeName}/${fileName}`; + const apiBase = API_BASE; + const token = await login(apiBase); + + console.log(`版本号: ${version}`); + console.log(`场景路径: ${saveKey}`); + const cdnLabel = args.target === 'prod' ? '正式 CDN' : '测试 CDN'; + console.log(`CDN: ${cdnLabel}(只影响下载地址,生产环境也可以从测试 CDN 安装)`); + console.log('正在获取签名...'); + const sign = await getCdnSign({ + apiBase, + token, + saveKey, + status, + }); + if (!sign.bucket || !sign.authorization || !sign.policy) { + throw new Error('签名返回缺少 bucket、authorization 或 policy'); + } + + console.log('正在上传 APK...'); + const uploaded = await uploadToUpyun({ + bucket: sign.bucket, + authorization: sign.authorization, + policy: sign.policy, + filePath: apkPath, + fileName, + }); + console.log('上传成功'); + const pathUrl = uploaded.url && String(uploaded.url).startsWith('http') + ? uploaded.url + : `${CDN_HOST[args.target]}${saveKey}`; + console.log(pathUrl); +} + +main().catch((error) => { + console.error(error.message || error); + process.exit(1); +});