From 0a1221933e037d7d7a80280cfcbafc8e45cdb78e Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 9 Jul 2026 19:18:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=85=E5=80=BCh5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aslan/recharge-activity/script.js | 78 ++++- aslan/recharge-activity/style.css | 13 +- common_aslan/aslan-common.js | 9 +- package-lock.json | 14 + package.json | 2 + scripts/aslan_flutter_webview.js | 511 ++++++++++++++++++++++++++++++ 6 files changed, 610 insertions(+), 17 deletions(-) create mode 100755 scripts/aslan_flutter_webview.js diff --git a/aslan/recharge-activity/script.js b/aslan/recharge-activity/script.js index c1e4ee2..88f098c 100644 --- a/aslan/recharge-activity/script.js +++ b/aslan/recharge-activity/script.js @@ -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(); }); diff --git a/aslan/recharge-activity/style.css b/aslan/recharge-activity/style.css index e4475f2..33b95e9 100644 --- a/aslan/recharge-activity/style.css +++ b/aslan/recharge-activity/style.css @@ -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; diff --git a/common_aslan/aslan-common.js b/common_aslan/aslan-common.js index 93073ca..c9a6179 100644 --- a/common_aslan/aslan-common.js +++ b/common_aslan/aslan-common.js @@ -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) { diff --git a/package-lock.json b/package-lock.json index 0a1776e..775d023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6aa851a..54f11dd 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/aslan_flutter_webview.js b/scripts/aslan_flutter_webview.js new file mode 100755 index 0000000..0628171 --- /dev/null +++ b/scripts/aslan_flutter_webview.js @@ -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 Aslan login account or special ID', + ' -p, --password Aslan login password', + ' --token Existing Aslan token; skips account password login', + ' -u, --url Target H5 URL. Relative paths use the local H5 dev server.', + ' --api-base API base, default https://api.atuchat.com/', + ' --origin Req-Sys-Origin origin, default ATYOU', + ' --origin-child Req-Sys-Origin originChild, default same as origin', + ' --lang Req-Lang, default en', + ' --chrome 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 Auto close, useful for smoke tests', + ' --screenshot 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); +});