feat: 新增命令行上传PDA安装包
This commit is contained in:
@@ -26,6 +26,7 @@ pnpm-lock.yaml
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
scripts/upload-apk.local.json
|
||||||
|
|
||||||
# Build output and cache
|
# Build output and cache
|
||||||
/.hbuilderx/
|
/.hbuilderx/
|
||||||
|
|||||||
@@ -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-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,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 <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 = 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"apiBase": "https://hcscmtest.zzfzyc.com/hcscm_weave"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user