From 232a3e179accb04403e03d88c419d6797eb5c83a Mon Sep 17 00:00:00 2001 From: xuan <1359774872@qq.com> Date: Tue, 13 Oct 2026 18:23:13 +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 --- .gitignore | 1 + package.json | 1 + scripts/upload-apk.js | 293 ++++++++++++++++++++++++++ scripts/upload-apk.local.example.json | 3 + 4 files changed, 298 insertions(+) create mode 100644 scripts/upload-apk.js create mode 100644 scripts/upload-apk.local.example.json diff --git a/.gitignore b/.gitignore index a29738a..6796079 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ pnpm-lock.yaml # Environment variables .env.local .env.*.local +scripts/upload-apk.local.json # Build output and cache /.hbuilderx/ diff --git a/package.json b/package.json index d704e4d..ce4146c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,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..a9279ac --- /dev/null +++ b/scripts/upload-apk.js @@ -0,0 +1,293 @@ +/** + * 上传已打包的 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 LOCAL_CONFIG = path.join(__dirname, 'upload-apk.local.json'); +const API_BASE_DEFAULT = 'https://hcscmtest.zzfzyc.com/hcscm_weave'; +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 loadApiBase() { + if (!fs.existsSync(LOCAL_CONFIG)) return API_BASE_DEFAULT; + const config = JSON.parse(fs.readFileSync(LOCAL_CONFIG, 'utf8')); + return String(config.apiBase || API_BASE_DEFAULT).replace(/\/+$/, ''); +} + +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 = loadApiBase(); + 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); +}); diff --git a/scripts/upload-apk.local.example.json b/scripts/upload-apk.local.example.json new file mode 100644 index 0000000..397b26f --- /dev/null +++ b/scripts/upload-apk.local.example.json @@ -0,0 +1,3 @@ +{ + "apiBase": "https://hcscmtest.zzfzyc.com/hcscm_weave" +}