Files
CyberStrikeAI/scripts/mobile-shot.mjs
T
Sycun 8e00147d06 增加手机端使用ui的体验和整体优化 (#344)
* 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 基线无回潮)。
2026-10-08 10:44:42 +08:00

185 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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);