feat: 新增命令行上传PDA安装包
This commit is contained in:
@@ -101,3 +101,37 @@ pack.json内容如下:详情可访问 [CLI uni-app发行 - Android/iOS云打
|
|||||||
```
|
```
|
||||||
### 云服务器走CICD 打包:
|
### 云服务器走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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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-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",
|
"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",
|
"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",
|
"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:android": "cross-env UNI_PLATFORM=app-plus UNI_OS_NAME=android jest -i",
|
||||||
"test:h5": "cross-env UNI_PLATFORM=h5 jest -i",
|
"test:h5": "cross-env UNI_PLATFORM=h5 jest -i",
|
||||||
|
|||||||
@@ -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 <apk路径> --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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user