This commit is contained in:
zhx 2026-07-09 19:18:58 +08:00
parent f776b87a01
commit 0a1221933e
6 changed files with 610 additions and 17 deletions

View File

@ -14,6 +14,7 @@
var DESIGN_WIDTH = 1080;
var VIEWPORT_WIDTH = 375;
var MAX_PAGE_WIDTH = 430;
var DEFAULT_DESIGN_HEIGHT = 3273;
var designHeight = DEFAULT_DESIGN_HEIGHT;
var countdownEndAt = resolveCountdownEndAt();
@ -112,7 +113,44 @@
);
}
function resolveCountdownEndAt() {
function monthlyResetEndAt() {
var now = new Date();
return new Date(
now.getFullYear(),
now.getMonth() + 1,
1,
0,
0,
0,
0
).getTime();
}
function activityTimeEndAt(activityTime) {
var matches = String(activityTime || '').match(
/(\d{1,2})\s*\/\s*(\d{1,2})\s*$/
);
if (!matches) return 0;
var now = new Date();
var day = Number(matches[1]);
var month = Number(matches[2]);
if (!Number.isFinite(day) || !Number.isFinite(month)) return 0;
// 后端月累充返回的 activityTime 是 DD/MM - DD/MM不带年份结束日次日 00:00 就是月度清空点。
var endAt = new Date(
now.getFullYear(),
month - 1,
day + 1,
0,
0,
0,
0
).getTime();
return Number.isFinite(endAt) && endAt > Date.now() ? endAt : 0;
}
function resolveCountdownEndAt(statusData) {
var params = getParams();
var explicitEnd = Date.parse(
params.get('end_at') || params.get('endAt') || ''
@ -124,23 +162,40 @@
if (Number.isFinite(hours) && hours > 0)
return Date.now() + hours * 60 * 60 * 1000;
// 后端月累充接口当前只返回活动文案周期,不返回毫秒级结束时间;倒计时仍支持 URL 显式覆盖,默认只作为页面视觉计时。
return Date.now() + 25 * 60 * 60 * 1000 + 61 * 1000;
return (
activityTimeEndAt(statusData && statusData.activityTime) ||
monthlyResetEndAt()
);
}
function getPageWidth() {
var viewportWidth =
window.innerWidth ||
document.documentElement.clientWidth ||
VIEWPORT_WIDTH;
return Math.min(Math.max(viewportWidth, 1), MAX_PAGE_WIDTH);
}
function updateScale() {
var scale = Math.min(window.innerWidth / VIEWPORT_WIDTH, 1);
var scaledHeight = Math.ceil(
designHeight * (VIEWPORT_WIDTH / DESIGN_WIDTH)
);
var pageWidth = getPageWidth();
var designScale = pageWidth / DESIGN_WIDTH;
var scaledHeight = Math.ceil(designHeight * designScale);
// 视觉稿仍以 1080 坐标布局,只把整张背景和内容按真实 WebView 宽度等比缩放,避免 412px 等机型露出页面两侧空白。
document.documentElement.style.setProperty(
'--app-scale',
String(scale)
'--page-width',
pageWidth + 'px'
);
document.documentElement.style.setProperty(
'--design-scale',
String(designScale)
);
viewport.style.minHeight = scaledHeight + 'px';
viewport.style.height = scaledHeight + 'px';
page.style.minHeight = scaledHeight + 'px';
document.body.style.minHeight = Math.ceil(scaledHeight * scale) + 'px';
page.style.height = scaledHeight + 'px';
document.body.style.minHeight =
Math.max(scaledHeight, window.innerHeight || 0) + 'px';
}
function updateCountdown() {
@ -518,6 +573,8 @@
return loader
.then(function (payload) {
var statusData = payload.status || {};
countdownEndAt = resolveCountdownEndAt(statusData);
updateCountdown();
state.user = normalizeUser(payload.profile, statusData);
state.rewards = normalizeRewards(statusData);
state.loading = false;
@ -577,6 +634,7 @@
claimReward(button.getAttribute('data-rule-id'));
});
window.addEventListener('resize', updateScale);
window.addEventListener('orientationchange', updateScale);
window.addEventListener('hyapp:i18n-ready', function () {
renderPage();
});

View File

@ -4,7 +4,7 @@
}
:root {
--app-scale: 1;
--page-width: 375px;
--design-scale: 0.3472222222;
}
@ -14,7 +14,7 @@ body {
width: 100%;
min-height: 100%;
overflow-x: hidden;
background: var(--hy-theme-bg);
background: #1b100d;
color: #f6e0b2;
font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;
}
@ -25,16 +25,17 @@ body {
.app-viewport {
position: relative;
width: 375px;
width: var(--page-width);
max-width: 430px;
min-height: 1137px;
margin: 0 auto;
transform: scale(var(--app-scale));
transform-origin: top center;
overflow: hidden;
}
.page {
position: relative;
width: 375px;
width: var(--page-width);
max-width: 430px;
min-height: 1137px;
overflow: hidden;
background: #1b100d;

View File

@ -16,7 +16,6 @@
'req-sys-origin': 'origin=ATYOU;originChild=ATYOU',
'req-version': 'V2',
'req-zone': 'Asia/Shanghai',
'Req-Atyou': 'true',
};
function currentParams() {
@ -430,6 +429,13 @@
});
}
function stripAppEncryptedHeaders(target) {
// H5 WebView 只复用 App 登录态,不参与 App Dio 加密;带 Req-Atyou 会让网关按密文解码普通 H5 请求。
Object.keys(target || {}).forEach(function (key) {
if (String(key).toLowerCase() === 'req-atyou') delete target[key];
});
}
function request(path, options) {
var opts = options || {};
var body = opts.body;
@ -438,6 +444,7 @@
}
return connectToApp().then(function () {
var requestHeaders = Object.assign({}, headers, opts.headers || {});
stripAppEncryptedHeaders(requestHeaders);
if (!requestHeaders.authorization)
delete requestHeaders.authorization;
if (body) {

14
package-lock.json generated
View File

@ -8,9 +8,23 @@
"name": "hyapp-h5",
"version": "1.0.0",
"devDependencies": {
"playwright-core": "^1.61.1",
"prettier": "^3.0.0"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",

View File

@ -5,10 +5,12 @@
"scripts": {
"dev:h5": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .",
"dev:manager-center": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .",
"aslan:webview": "node scripts/aslan_flutter_webview.js",
"format": "prettier --write \"**/*.{js,html,css,json,md}\"",
"format:check": "prettier --check \"**/*.{js,html,css,json,md}\""
},
"devDependencies": {
"playwright-core": "^1.61.1",
"prettier": "^3.0.0"
}
}

511
scripts/aslan_flutter_webview.js Executable file
View File

@ -0,0 +1,511 @@
#!/usr/bin/env node
'use strict';
const childProcess = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const readline = require('node:readline/promises');
const { stdin, stdout } = require('node:process');
const { chromium } = require('playwright-core');
const PROJECT_ROOT = path.resolve(__dirname, '..');
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 8788;
const DEFAULT_API_BASE = 'https://api.atuchat.com/';
const DEFAULT_URL =
'/aslan/recharge-activity/index.html?app_code=aslan&codex_webview=1';
const DEFAULT_CHROME_PATHS = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
];
function parseArgs(argv) {
const options = {
account: '',
password: '',
token: '',
url: '',
apiBase: DEFAULT_API_BASE,
host: DEFAULT_HOST,
port: DEFAULT_PORT,
origin: 'ATYOU',
originChild: '',
lang: 'en',
chrome: '',
headless: false,
devtools: false,
disableWebSecurity: true,
navigationPrompt: true,
closeAfter: 0,
screenshot: '',
};
const aliases = {
'-a': 'account',
'--account': 'account',
'-p': 'password',
'--password': 'password',
'--pwd': 'password',
'--token': 'token',
'-u': 'url',
'--url': 'url',
'--api-base': 'apiBase',
'--host': 'host',
'--port': 'port',
'--origin': 'origin',
'--origin-child': 'originChild',
'--lang': 'lang',
'--chrome': 'chrome',
'--close-after': 'closeAfter',
'--screenshot': 'screenshot',
};
for (let i = 0; i < argv.length; i += 1) {
const raw = argv[i];
const [flag, inlineValue] = raw.split(/=(.*)/s);
if (flag === '--help' || flag === '-h') {
options.help = true;
continue;
}
if (flag === '--headless') {
options.headless = true;
continue;
}
if (flag === '--devtools') {
options.devtools = true;
continue;
}
if (flag === '--no-disable-web-security') {
options.disableWebSecurity = false;
continue;
}
if (flag === '--no-nav-prompt') {
options.navigationPrompt = false;
continue;
}
const key = aliases[flag];
if (!key) {
throw new Error(`Unknown argument: ${raw}`);
}
const value = inlineValue !== undefined ? inlineValue : argv[++i];
if (value === undefined) throw new Error(`Missing value for ${raw}`);
options[key] =
key === 'port' || key === 'closeAfter' ? Number(value) : value;
}
options.originChild = options.originChild || options.origin;
return options;
}
function usage() {
return [
'Usage:',
' npm run aslan:webview',
' npm run aslan:webview -- --account 6666 --password 123456 --url /aslan/recharge-activity/index.html?app_code=aslan',
'',
'Options:',
' -a, --account <value> Aslan login account or special ID',
' -p, --password <value> Aslan login password',
' --token <value> Existing Aslan token; skips account password login',
' -u, --url <value> Target H5 URL. Relative paths use the local H5 dev server.',
' --api-base <url> API base, default https://api.atuchat.com/',
' --origin <value> Req-Sys-Origin origin, default ATYOU',
' --origin-child <value> Req-Sys-Origin originChild, default same as origin',
' --lang <value> Req-Lang, default en',
' --chrome <path> Chrome executable path',
' --headless Run browser headless',
' --devtools Open DevTools',
' --no-disable-web-security Keep browser web security enabled',
' --no-nav-prompt Do not prompt for more URLs after opening',
' --close-after <seconds> Auto close, useful for smoke tests',
' --screenshot <path> Save a screenshot after initial load',
].join('\n');
}
function normalizeBaseURL(value) {
return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
}
function resolveChromePath(explicitPath) {
if (explicitPath) return explicitPath;
return (
DEFAULT_CHROME_PATHS.find((candidate) => fs.existsSync(candidate)) || ''
);
}
function normalizeTargetURL(input, host, port) {
const value = String(input || DEFAULT_URL).trim() || DEFAULT_URL;
if (/^https?:\/\//i.test(value)) return value;
const cleanPath = value.startsWith('/')
? value
: `/${value.replace(/^\.?\//, '')}`;
return `http://${host}:${port}${cleanPath}`;
}
function isLocalTarget(url, host, port) {
try {
const parsed = new URL(url);
return (
parsed.hostname === host &&
String(parsed.port || 80) === String(port)
);
} catch (_) {
return false;
}
}
function healthURL(host, port) {
return `http://${host}:${port}/__h5_dev_server__/health`;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function requestHealth(url) {
return new Promise((resolve) => {
const req = http.get(url, (res) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.setTimeout(800, () => {
req.destroy();
resolve(false);
});
});
}
async function ensureLocalServer(host, port) {
const url = healthURL(host, port);
if (await requestHealth(url)) return null;
const server = childProcess.spawn(
'python3',
[
path.join(PROJECT_ROOT, 'scripts/h5_dev_server.py'),
'--host',
host,
'--port',
String(port),
'--directory',
PROJECT_ROOT,
],
{
cwd: PROJECT_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
}
);
server.stdout.on('data', (chunk) =>
process.stdout.write(`[h5-server] ${chunk}`)
);
server.stderr.on('data', (chunk) =>
process.stderr.write(`[h5-server] ${chunk}`)
);
for (let i = 0; i < 30; i += 1) {
if (await requestHealth(url)) return server;
await wait(200);
}
server.kill();
throw new Error(`Failed to start local H5 server: ${url}`);
}
async function askRequired(rl, label, currentValue) {
if (currentValue) return currentValue;
const value = (await rl.question(label)).trim();
if (!value)
throw new Error(`${label.replace(/[:]\s*$/, '')} is required`);
return value;
}
async function askPassword(rl, currentValue) {
if (currentValue) return currentValue;
if (!stdin.isTTY) return askRequired(rl, 'Password: ', currentValue);
return new Promise((resolve, reject) => {
let value = '';
stdout.write('Password: ');
stdin.setRawMode(true);
stdin.resume();
const onData = (buffer) => {
const text = buffer.toString('utf8');
if (text === '\u0003') {
stdin.setRawMode(false);
stdin.off('data', onData);
reject(new Error('Cancelled'));
return;
}
if (text === '\r' || text === '\n') {
stdin.setRawMode(false);
stdin.off('data', onData);
stdout.write('\n');
resolve(value);
return;
}
if (text === '\u007f') {
if (value.length > 0) {
value = value.slice(0, -1);
stdout.write('\b \b');
}
return;
}
value += text;
stdout.write('*');
};
stdin.on('data', onData);
});
}
function buildReqAppIntel(model, channel, imei) {
return [
'build=1',
'version=1.0.0',
`model=${model}`,
'sysVersion=web',
`channel=${channel}`,
`Req-Imei=${imei}`,
].join(';');
}
async function login(options) {
const imei = 'codex-aslan-webview';
const response = await fetch(
`${normalizeBaseURL(options.apiBase)}/auth/account/login`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'req-lang': options.lang,
'req-client': 'Android',
'req-imei': imei,
'req-app-intel': buildReqAppIntel(
'CodexWebViewLogin',
'Codex',
imei
),
'req-sys-origin': `origin=${options.origin};originChild=${options.originChild}`,
'req-version': 'V2',
'req-zone': 'Asia/Shanghai',
'user-agent': 'curl/8.7.1',
},
body: JSON.stringify({
account: options.account,
pwd: options.password,
}),
}
);
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : {};
} catch (_) {
throw new Error(
`Login returned non-JSON response: HTTP ${response.status} ${text.slice(0, 200)}`
);
}
if (
!response.ok ||
payload.status === false ||
!payload.body ||
!payload.body.token
) {
throw new Error(
`Login failed: HTTP ${response.status} ${payload.errorMsg || payload.message || text.slice(0, 200)}`
);
}
return {
token: payload.body.token,
profile: payload.body.userProfile || {},
};
}
function buildInitScriptPayload(options, token) {
const imei = 'codex-aslan-webview';
return {
token,
lang: options.lang,
origin: options.origin,
originChild: options.originChild,
reqAppIntel: buildReqAppIntel('CodexFlutterWebView', 'Codex', imei),
};
}
async function openWebView(options, token, firstURL) {
const chromePath = resolveChromePath(options.chrome);
if (!chromePath) {
throw new Error(
'Chrome executable not found. Pass --chrome /path/to/chrome.'
);
}
const launchArgs = ['--window-size=390,844'];
if (options.disableWebSecurity) {
launchArgs.push('--disable-web-security');
launchArgs.push('--disable-features=IsolateOrigins,site-per-process');
}
const browser = await chromium.launch({
executablePath: chromePath,
headless: options.headless,
devtools: options.devtools,
args: launchArgs,
});
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (Linux; Android 14; Aslan Flutter WebView) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36',
});
await context.addInitScript(
(config) => {
window.FlutterApp = window.FlutterApp || {
postMessage(data) {
console.log('[FlutterApp.postMessage]', data);
},
};
window.FlutterPageControl = window.FlutterPageControl || {
postMessage(data) {
console.log('[FlutterPageControl.postMessage]', data);
},
};
window.app = window.app || {};
window.app.getAccessOrigin = function getAccessOrigin() {
return JSON.stringify({
Authorization: `Bearer ${config.token}`,
'Req-Lang': config.lang,
'Req-App-Intel': config.reqAppIntel,
'Req-Sys-Origin': `origin=${config.origin};originChild=${config.originChild}`,
});
};
window.app.getAuth = function getAuth() {
return window.app.getAccessOrigin();
};
window.app.sendToFlutter = function sendToFlutter(data) {
window.FlutterApp.postMessage(data);
};
},
buildInitScriptPayload(options, token)
);
const page = await context.newPage();
page.on('console', (message) => {
const type = message.type();
if (type === 'error' || type === 'warning') {
console.log(`[webview:${type}] ${message.text()}`);
}
});
await page.goto(firstURL, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page
.waitForLoadState('networkidle', { timeout: 15000 })
.catch(() => {});
return { browser, page };
}
async function promptNavigationLoop(rl, page, options) {
console.log(
'输入新的 URL 回车可跳转;输入 q 退出。相对路径会走本地 H5 服务。'
);
while (!page.isClosed()) {
const next = (await rl.question('WebView URL> ')).trim();
if (!next) continue;
if (next.toLowerCase() === 'q' || next.toLowerCase() === 'quit') break;
const targetURL = normalizeTargetURL(next, options.host, options.port);
if (isLocalTarget(targetURL, options.host, options.port)) {
await ensureLocalServer(options.host, options.port);
}
console.log(`Navigate: ${targetURL}`);
await page.goto(targetURL, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page
.waitForLoadState('networkidle', { timeout: 15000 })
.catch(() => {});
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
options.account = options.account || process.env.ASLAN_ACCOUNT || '';
options.password = options.password || process.env.ASLAN_PASSWORD || '';
options.token = options.token || process.env.ASLAN_TOKEN || '';
options.url = options.url || process.env.ASLAN_WEBVIEW_URL || '';
const rl = readline.createInterface({ input: stdin, output: stdout });
let localServer = null;
let browser = null;
try {
if (!options.token) {
options.account = await askRequired(
rl,
'Account: ',
options.account
);
options.password = await askPassword(rl, options.password);
}
options.url =
options.url ||
(await rl.question(`URL [${DEFAULT_URL}]: `)).trim() ||
DEFAULT_URL;
const targetURL = normalizeTargetURL(
options.url,
options.host,
options.port
);
if (isLocalTarget(targetURL, options.host, options.port)) {
localServer = await ensureLocalServer(options.host, options.port);
}
const loginResult = options.token
? { token: options.token, profile: { account: 'token' } }
: await login(options);
const displayAccount =
loginResult.profile.account ||
loginResult.profile.id ||
options.account;
console.log(
`${options.token ? 'Token loaded' : 'Login OK'}: ${loginResult.profile.userNickname || '-'} / ${displayAccount}`
);
console.log(`Open WebView: ${targetURL}`);
const result = await openWebView(options, loginResult.token, targetURL);
browser = result.browser;
if (options.screenshot) {
await result.page.screenshot({
path: path.resolve(options.screenshot),
fullPage: true,
});
console.log(
`Screenshot saved: ${path.resolve(options.screenshot)}`
);
}
if (options.closeAfter > 0) {
await wait(options.closeAfter * 1000);
return;
}
if (options.navigationPrompt && !options.headless) {
await promptNavigationLoop(rl, result.page, options);
return;
}
await result.page.waitForEvent('close').catch(() => {});
} finally {
rl.close();
if (browser) await browser.close().catch(() => {});
if (localServer) localServer.kill();
}
}
main().catch((error) => {
console.error(error.message || error);
process.exit(1);
});