mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 21:47:17 +02:00
* feat: integrate the mobile UI runtime layer from the migration package 迁移包给出的移动端改造是「运行时注入」架构:mobile.css 只写 767.98px 以下的 规则,mobile-ui.js 在末位加载并包装 switchPage、注入抽屉/底部标签栏/更多面板。 模板侧只有 4 处接线(viewport-fit + interactive-widget、样式层链接、ELK/SheetJS 移动端延迟加载、交互层末位脚本),其余保持上游原样,桌面端零影响。 * feat: 移动端注入层接入 i18n,并把触控下限统一到 44px 迁移包的交互层把标签栏、抽屉、更多面板的文案全写死成中文,en-US/ru-RU 下会中英 混杂;现在所有注入节点统一挂 data-i18n,交给 i18n.js 的 applyTranslations 在语言 切换时自动重译,本层只保留中文兜底。语言项改为按桌面端下拉里真实存在的语言循环 (原来只在 zh/en 之间来回,俄语用户点不到),面板里的 API 文档/GitHub 不再抄一份 URL,改为触发桌面端既有控件;iOS 上没有全屏 API 时给出提示而不是静默无反应; 不在四个标签里的页面点亮"更多",否则三分之一的页面底部栏无任何高亮。 同时补齐 PWA manifest、api-docs 页移动端样式层,并把通知铃铛与头像按钮从 38px 提到本层自己的 --m-touch(44px) 口径。配 web/static/js/mobile-ui-i18n.test.cjs: 键集三方一致、引用键必须三语都有译文、helper 区之外不许出现裸写文案的 API。 * style: 移动端外壳改 iOS 观感,并修掉抽屉页脚错位 观感:顶栏/底栏改透出材质+发丝分隔线,图标按钮一律正圆去底框,文字按钮一律胶囊, 卡片 18、面板顶角 22,按压给 scale 反馈并在 prefers-reduced-motion 下关掉;焦点环 从 2px 硬描边换成 3px 柔光环(forced-colors 下把硬描边还回去)。材质一律用 color-mix 从主题自己的 --bg-primary/--border-color 派生,浅色暗色都跟随。 两处不是"不好看"而是真错: - 抽屉页脚被 insertBefore 插到菜单上面,"返回顶部"出现在列表上方; - 顶栏材质在暗色下被 style.css 的 html[data-theme="dark"] header 不透明渐变压住, 只写 header 选择器不生效,改成同特异性后暗色才真正透出。 圆角与胶囊走 --m-r-icon/--m-r-btn 令牌(24 节那批 !important 规则读的就是它们), 不再往下补选择器与 !important 对轰。 配 scripts/mobile-shot.mjs:手机视口下抓外壳图,--why 用 CDP 匹配样式表回答 "规则写了没生效是没命中还是被谁压住",--eval 量真实计算值。 * fix: 抽屉页脚与底部固定件被 Safari 工具栏压在后面 iOS 的 100dvh 是「工具栏收起时」的最大视口高度,工具栏展开时可见高度只有 innerHeight。两个全屏抽屉写死 height:100dvh,于是页脚那一截正好落在 Safari 底栏后面;bottom:0 的底栏与贴底面板同理。现在抽屉改用 mobile-ui.js 每次 resize 重写的 --m-app-height,贴底固定件按 max(0px, 100dvh - 可见高度) 抬升;不支持 dvh 或桌面端抬升量恒为 0,实测默认态底栏/抽屉位置零位移。 * fix: 去掉底部标签栏里重复的「更多」入口 顶栏的 ··· 与标签栏的「更多」打开的是同一个面板。按一个入口一份实现收掉底部那枚, 标签栏只留四个页面跳转;随之清掉 more2 图标、mobile.tabMore 键与门禁里的点击断言。 落点在四个标签之外的页面不再高亮任何标签——没有「更多」可点了。 * fix: 清掉另外两处重复入口,并把同类视口缺陷扫干净 顺着「底部更多与顶栏 ··· 重复」这条线扫了一遍,还有两处同类: - 面板里的「退出登录」与头像菜单里的退出是同一个动作两个入口,头像菜单是桌面端 一贯的位置,去掉面板那枚;连带删掉只有它能触发的 m-danger 样式与 logout 图标。 - 抽屉页脚的「返回顶部」与面板里的返回顶部是同一个动作两个入口,且抽屉是导航容器, 动作按钮不该住在里面;去掉整个页脚(那句"移动端导航"文案也没有信息量)。 同类视口缺陷两处: - #m-toast 的 bottom 只算到 tabbar-h + safe,底栏按 --m-bar-bottom 抬升时提示条不抬, 工具栏展开时会压在底栏上;实测补前后间距 16px -> 16px。 - api-docs 页容器写 min-height:100dvh,恒定比可见区高出一截,最后一行落在 Safari 底栏后面;改成 100svh(最小视口)。 i18n 契约测试同步:header.logout / mobile.navFooter 不再被引用,mobile 命名空间 18 键。 * style: 手机端对齐 new-api 的卡片密度与 iOS 的填充式表单 仪表盘那排 KPI 在 ≤480px 被桌面样式拍成一列 116px 高的长条,手机上要滚两屏才看完 四个数字。收成两列紧凑网格(奇数张末卡通栏不留空洞),数字 26px/600 + tabular-nums 做主角,标签 12px 弱化,图标块 28px。卡片表面统一成发丝边 + 16 圆角 + 几乎看不见的 投影——桌面的双层阴影在小屏上一屏四张会散。表单改 iOS 填充式(靠底色区分字段), 聚焦只留一圈柔光环(登录框实测叠过两圈),登录卡 20 圆角 + 满宽胶囊主按钮。 KPI 按 nth-child 分的淡彩渐变是产品自己的身份,这一节不跟它抢。 * fix: 补生产侧俄语、修掉白底白字的提示条,并把逐按钮门禁跑到 PASS 用户报的两件事: - 面板里「切换语言」只在中英之间跳 —— 不是逻辑问题,是 ~/csai-生产版 缺俄语资产 (模板没有 ru-RU 选项、i18n/ 只有两个 json、i18n.js 是不认 ru 的旧版)。补齐后 真手指连点验通 中→英→俄→中,且该行文字自身跟着换。 - 点面板按钮时屏幕中间冒出一块白的 —— 是我自己造成的:25.6 把提示条改成浅色材质, 字色还是基底的 #fff,等于白底白字。回到深色 HUD 胶囊,只留一层投影。 逐按钮门禁(需要登录态,本轮首次跑通全量 34 个界面 = 31 页 + 抽屉/面板/会话抽屉展开态): - 硬缺陷 covered/offscreen/zero/pointer-events 全 0;11 条外壳真手指点击全过; - 小于 44px 从 79 降到 3,残留是桌面密集表格里的控件,钉进基线只降不升; - 修掉的真缺陷:子菜单项 42px(本层自己漏的下限)、.ai-channel-manager 的 overflow:hidden 把 4 个通道选择按钮裁到滚不进来、会话抽屉里搜索框 42/分组按钮 24 宽/ 项目行 38/折叠头 30、工具条里 <a onclick> 只有 21px 高(这些链接既没 href 也没 onclick 属性可依赖,判据要放宽到 <a>);复选框口径统一到 24.11 一处并放大到 30px。 - 门禁自己的两个错也修了:scrollIntoView 在「外层 hidden + 内层 auto」的嵌套里根本不滚, 造成 59 个 offscreen 假红,改成逐层滚可滚祖先模拟手指;touch+mouse 双发会让一次点击 变成两次(抽屉开了又关),只发 touch。 - 退出码口径写死:够不到/遮挡/零尺寸/pointer-events 与自家外壳必须 0,桌面遗留小命中区 走基线;基线被压低时注入探针验红(RATCHET FAIL ×2)后撤销复验绿。 * style: 手机端玻璃质感升级,并补上对话上传的安卓适配 观感目标从"iOS 化的平面灰底"升到玻璃质感。平底上做半透明无物可透,玻璃读不出来, 所以先造画布再上材质(mobile.css 第 28 节): - .container 铺环境光(浅色=近白底+右上 accent/左下紫柔光,暗色=深底+更亮柔光), .content-area / .chat-messages 交出实心底色,半透明表面从此有色可透; - 模糊只给小面积/瞬时件:顶栏、底栏、底部面板、抽屉、登录卡、弹窗;常驻卡片 只做半透明+发丝边+顶缘高光,不挂模糊——固定在滚动内容上的大面积模糊是掉帧主因 (25 节实测口径不变);浅深色全用 color-mix 从主题令牌派生,只有顶缘高光按主题 换向;prefers-reduced-transparency 与不支持模糊的环境退回实心。 - index.html 的 mobile.css 版本号递增,防 iPhone Safari 吃旧缓存。 安卓适配:对话上传的 accept="*" 不是合法 MIME 通配(各浏览器/WebView 解析不一致), 改为 "*/*";mobile-tap-audit.mjs 新增 --ua=iphone|android,同一套门禁可换安卓 UA 复跑 (壳层按视口宽度判定、与 UA 无关,换 UA 是给"安卓浏览器下同样合格"留可复现证据)。 验证(都在生产版文件夹跑):iPhone/Android 双 UA 端到端上传(按钮 44px → 触达 选择器 → POST /api/chat-uploads 200 落盘 → 附件条渲染)PASS;双 UA 全量门禁各 34 页 PASS(硬缺陷 0、点击失败 0、44px 基线无回潮)。
185 lines
9.8 KiB
JavaScript
185 lines
9.8 KiB
JavaScript
/*
|
||
* CyberStrikeAI — mobile shell screenshots.
|
||
*
|
||
* Captures the injected mobile chrome (appbar / tab bar / drawer / bottom sheet)
|
||
* at a phone viewport without needing a login: the chrome is injected by
|
||
* mobile-ui.js boot() purely from the viewport width, so hiding the login
|
||
* overlay is enough to make syncAuthChrome re-show the tab bar. Data-driven page
|
||
* bodies stay empty — this is for judging the *shell*, not the content.
|
||
*
|
||
* node scripts/mobile-shot.mjs --state=plain --out=/tmp/shot.png
|
||
* node scripts/mobile-shot.mjs --state=drawer --base=http://127.0.0.1:8088
|
||
* states: login | plain | drawer | sheet | chat
|
||
*/
|
||
import fs from 'node:fs';
|
||
import { setTimeout as sleep } from 'node:timers/promises';
|
||
|
||
const argv = Object.fromEntries(process.argv.slice(2).map(a => {
|
||
const i = a.indexOf('=');
|
||
return i === -1 ? [a.replace(/^--/, ''), '1'] : [a.slice(2, i), a.slice(i + 1)];
|
||
}));
|
||
const W = Number(argv.width || 390), H = Number(argv.height || 844);
|
||
const PORT = argv.port || '9713';
|
||
const BASE = (argv.base || 'http://127.0.0.1:8088').replace(/\/$/, '');
|
||
const STATE = argv.state || 'plain';
|
||
const OUT = argv.out || `/tmp/cs-shot/${STATE}-${W}x${H}.png`;
|
||
|
||
fs.mkdirSync(dirnameOf(OUT), { recursive: true });
|
||
function dirnameOf(p) { const i = p.lastIndexOf('/'); return p.slice(0, i) || '.'; }
|
||
|
||
const CH = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||
const chrome = await import('node:child_process').then(m =>
|
||
m.spawn(CH, ['--headless=new', `--remote-debugging-port=${PORT}`,
|
||
`--user-data-dir=/tmp/cs-shot-${PORT}`, '--no-sandbox', 'about:blank'], { stdio: 'ignore' }));
|
||
process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* gone */ } });
|
||
|
||
let ws;
|
||
for (let i = 0; i < 80; i++) {
|
||
try {
|
||
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||
ws = new WebSocket((await r.json()).webSocketDebuggerUrl);
|
||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; setTimeout(rej, 4000); });
|
||
break;
|
||
} catch { await sleep(300); }
|
||
}
|
||
if (!ws) { console.error('chrome devtools unreachable'); process.exit(1); }
|
||
|
||
let idc = 0; const pending = new Map();
|
||
ws.onmessage = e => {
|
||
const m = JSON.parse(e.data);
|
||
if (m.id && pending.has(m.id)) {
|
||
const { res, rej } = pending.get(m.id); pending.delete(m.id);
|
||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||
}
|
||
};
|
||
const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
|
||
const id = ++idc; pending.set(id, { res, rej });
|
||
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||
});
|
||
|
||
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
|
||
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
|
||
for (const m of ['Page.enable', 'Runtime.enable']) await send(m, {}, sessionId);
|
||
await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId);
|
||
await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId);
|
||
await send('Network.setUserAgentOverride', {
|
||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||
}, sessionId);
|
||
const ev = async expr => {
|
||
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }, sessionId);
|
||
if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) };
|
||
return r.result?.value;
|
||
};
|
||
|
||
/* 主题要先落进本站点的 localStorage,about:blank 上写是写给另一个 origin。
|
||
换主题用 Page.navigate 重新进,不要在 eval 里 location.reload()——awaitPromise
|
||
会等一个永远不回来的执行上下文。 */
|
||
await send('Page.navigate', { url: BASE + '/' }, sessionId);
|
||
await sleep(1500);
|
||
const theme = argv.theme || (argv.dark === '1' ? 'dark' : '');
|
||
if (theme) {
|
||
await ev(`localStorage.setItem('cyberstrike-theme', ${JSON.stringify(theme)})`);
|
||
await send('Page.navigate', { url: BASE + '/' }, sessionId);
|
||
}
|
||
await sleep(3000);
|
||
|
||
if (STATE !== 'login') {
|
||
/* 藏掉登录遮罩会触发 mobile-ui.js 的 MutationObserver,把底部栏显示回来 */
|
||
await ev(`document.getElementById('login-overlay').style.display='none'`);
|
||
await sleep(600);
|
||
if (STATE === 'drawer') await ev(`window.CSAMobile && CSAMobile.openDrawer()`);
|
||
if (STATE === 'sheet') await ev(`window.CSAMobile && CSAMobile.openSheet()`);
|
||
if (STATE === 'chat') { await ev(`window.switchPage && switchPage('chat')`); await sleep(1500); }
|
||
await sleep(1200);
|
||
}
|
||
|
||
const state = await ev(`(()=>({
|
||
booted: !!window.CSAMobile,
|
||
tabbar: !!document.querySelector('#m-tabbar') && getComputedStyle(document.querySelector('#m-tabbar')).display,
|
||
sheet: !!document.querySelector('#m-sheet'),
|
||
drawerOpen: !!document.querySelector('#main-sidebar.m-open'),
|
||
sheetOpen: !!document.querySelector('#m-sheet.m-show'),
|
||
lang: document.documentElement.getAttribute('lang')
|
||
}))()`);
|
||
console.log(JSON.stringify(state));
|
||
|
||
/* 带凭据时真的登进去:未登录时对话/列表页的容器根本不渲染,看不到真东西。
|
||
凭据同样只从参数或环境变量来,绝不写进文件。 */
|
||
const USER = argv.user || process.env.CSAI_AUDIT_USER || '';
|
||
const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || '';
|
||
const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || '';
|
||
if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: new URL(BASE).hostname, path: '/' }, sessionId);
|
||
if (USER && PASS) {
|
||
await ev(`(()=>{const u=document.getElementById('login-username'),p=document.getElementById('login-password');if(u&&p){u.value=${JSON.stringify(USER)};p.value=${JSON.stringify(PASS)};document.getElementById('login-form').requestSubmit();}})()`);
|
||
await sleep(7000);
|
||
console.log('logged in:', JSON.stringify(await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`)));
|
||
}
|
||
if (argv.route) { await ev(`location.hash=${JSON.stringify(argv.route)}`); await sleep(3000); }
|
||
|
||
/* --eval='...' 用来在同一个已登录/已注入的页面上量一把真实计算样式 */
|
||
if (argv.eval) {
|
||
const probe = await ev(`(function(){${argv.eval}})()`);
|
||
console.log(JSON.stringify(probe, null, 2));
|
||
}
|
||
|
||
/* --why='.notification-btn' --prop=border-radius
|
||
规则写了却不生效时,用 CDP 的匹配样式表分清:没命中,还是被更高优先级压住。 */
|
||
if (argv.why) {
|
||
await send('DOM.enable', {}, sessionId);
|
||
await send('CSS.enable', {}, sessionId);
|
||
const { root } = await send('DOM.getDocument', { depth: -1 }, sessionId);
|
||
const { nodeId } = await send('DOM.querySelector', { nodeId: root.nodeId, selector: argv.why }, sessionId);
|
||
if (!nodeId) { console.log('selector 未命中任何节点:', argv.why); }
|
||
else {
|
||
const matched = await send('CSS.getMatchedStylesForNode', { nodeId }, sessionId);
|
||
const want = (argv.prop || 'border-radius,border,background').split(',');
|
||
const rows = [];
|
||
for (const e of matched.matchedCSSRules || []) {
|
||
const m = e.rule;
|
||
const decl = (m.style.cssProperties || []).filter(p => want.includes(p.name) && !p.disabled && p.value);
|
||
if (!decl.length) continue;
|
||
const sel = (m.selectorList && m.selectorList.text) || '?';
|
||
rows.push({
|
||
origin: m.origin,
|
||
sheet: (m.style.styleSheetId || '').split('@').pop(),
|
||
selector: sel.length > 78 ? sel.slice(0, 78) + '…' : sel,
|
||
decls: decl.map(d => `${d.name}:${d.value}${d.important ? ' !important' : ''}`),
|
||
});
|
||
}
|
||
console.log(`== ${argv.why} 上命中且声明了 [${want.join(', ')}] 的规则,按级联顺序(后=更优先)==`);
|
||
rows.forEach((r, i) => console.log(`${String(i).padStart(2)} ${r.origin.padEnd(10)} ${r.decls.join(' | ')}\n ${r.selector}`));
|
||
const cs = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.why)});const c=getComputedStyle(e);return JSON.stringify(${JSON.stringify(want)}.reduce((o,k)=>(o[k]=c[k],o),{}),null,1)})()`);
|
||
console.log('最终计算值:', cs);
|
||
}
|
||
}
|
||
|
||
/* --tap='SEL' 用真手指(只发 touch)点一个选择器,配合 --shots=3 连拍,
|
||
用来看"点下去之后屏幕上闪出来的那块是什么"。 */
|
||
async function fingerTap(x, y) {
|
||
const t = { x: Math.round(x), y: Math.round(y) };
|
||
await send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [t] }, sessionId);
|
||
await sleep(40);
|
||
await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId);
|
||
}
|
||
if (argv.tap) {
|
||
const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.tap)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2}})()`);
|
||
if (!box) console.log('tap 目标不存在:', argv.tap);
|
||
else {
|
||
await fingerTap(box.x, box.y);
|
||
const shots = Number(argv.shots || 3);
|
||
for (let i = 0; i < shots; i++) {
|
||
const png = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||
fs.writeFileSync(OUT.replace(/\.png$/, '') + `-t${i}.png`, Buffer.from(png.data, 'base64'));
|
||
const who = await ev(`(()=>{const e=document.elementFromPoint(innerWidth/2,innerHeight/2);if(!e)return null;var s=e.tagName.toLowerCase()+(e.id?"#"+e.id:"")+(typeof e.className==="string"&&e.className?"."+e.className.trim().split(/\s+/)[0]:"");var r=e.getBoundingClientRect();var c=getComputedStyle(e);return JSON.stringify({el:s,z:c.zIndex,pos:c.position,box:[Math.round(r.width),Math.round(r.height)],bg:c.backgroundColor})})()`);
|
||
console.log(`t${i} 视口中央:`, who);
|
||
await sleep(Number(argv.shotDelay || 300));
|
||
}
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||
fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64'));
|
||
console.log('shot -> ' + OUT);
|
||
process.exit(0);
|