增加手机端使用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 基线无回潮)。
This commit is contained in:
Sycun authored and GitHub committed 2026-10-08 10:44:42 +08:00
1 parent d2084a9481
commit 8e00147d06
13 files changed
+6050 -7

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
/*
* 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);
+460
View File
@@ -0,0 +1,460 @@
/*
* CyberStrikeAI — mobile tap audit.
*
* Proves the claim "every button on a phone can actually be tapped and shows up
* correctly". It is deliberately narrower than scripts/mobile-audit.mjs (layout
* defects): here every interactive element in the *active* page must pass four
* geometric/reachability checks, and the injected mobile chrome must pass a
* scripted finger tap.
*
* node scripts/mobile-tap-audit.mjs --user=admin --pass=... --width=390 --height=844
* CSAI_AUDIT_USER=admin CSAI_AUDIT_PASS=... node scripts/mobile-tap-audit.mjs --all
* node scripts/mobile-tap-audit.mjs --cookie=<auth_token 的值> --all
*
* Flags: --base=https://127.0.0.1:8088 --pages=a,b,c --allow-writes=0|1 --ua=iphone|android
* --chrome=0 (skip the finger-tap suite) --json=<path>
*
* Nothing destructive is tapped: elements whose label matches a write verb are
* measured but never clicked, and 新对话-style writes need --allow-writes=1.
* Credentials are never defaulted — a baked-in password leaks the moment this
* file is committed or served.
*/
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 || '9712';
const BASE = (argv.base || 'https://127.0.0.1:8088').replace(/\/$/, '');
const USER = argv.user || process.env.CSAI_AUDIT_USER || '';
const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || '';
/* 不想交出口令时的替代路径:在浏览器里登录 127.0.0.1:8088,从 DevTools 复制
auth_token 的值,用 --cookie=... 或 CSAI_AUDIT_COOKIE=... 传进来。会话本身 12 小时
过期,比长期有效的主机口令暴露面小。两者都没有则拒绝运行。 */
const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || '';
const ALLOW_WRITES = argv['allow-writes'] === '1';
const MIN_TAP = Number(argv['min-tap'] || 44);
const CHROME = argv.chrome !== '0';
const OUT = argv.out || `/tmp/cs-tap/${W}x${H}`;
const JSON_OUT = argv.json || `${OUT}/tap-audit.json`;
const BASELINE = argv.baseline || 'scripts/mobile-tap-baseline.json';
if (!COOKIE && (!USER || !PASS)) {
console.error('Refusing to run without credentials: pass --user=... --pass=... (or CSAI_AUDIT_USER/CSAI_AUDIT_PASS), or an existing session via --cookie=... (or CSAI_AUDIT_COOKIE).');
process.exit(2);
}
const SPA_PAGES = (argv.pages || (argv.all ? '' : 'dashboard,chat,hitl,tasks'))
.split(',').filter(Boolean);
const PAGES = argv.all
? ['dashboard', 'chat', 'projects', 'vulnerabilities', 'tasks', 'workflows', 'asset-overview',
'asset-library', 'info-collect', 'hitl', 'tool-guard', 'webshell', 'c2-listeners', 'c2-sessions',
'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles', 'chat-files', 'mcp-monitor', 'mcp-management',
'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac',
'skills-monitor', 'skills-management', 'agents-management', 'settings', 'audit-log', 'monitor']
: SPA_PAGES;
fs.mkdirSync(OUT, { recursive: true });
/* Write verbs: an element carrying one of these in its label is measured, never
tapped. Without this the audit would delete real rows in whatever instance it
points at. */
const WRITE_RE = /(删除|清空|清理|退出|注销|重启|停止|终止|中止|执行|运行|发送|提交|保存|新建|新增|创建|导入|上传|应用|重置|启用|禁用|安装|卸载|开始|立即|一键|批量|覆盖|恢复|回滚|delete|remove|clear|logout|submit|save|create|import|upload|apply|reset|install|uninstall|run|execute|send|restart|stop|kill|rollback|restore)/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-tap-${PORT}`, '--ignore-certificate-errors',
'--no-sandbox', '--allow-running-insecure-content', 'about:blank'], { stdio: 'ignore' }));
/* 任何提前退出(连不上、登录失败)都不许把 headless Chrome 留在机器上 */
process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* 已退出 */ } });
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(); const events = [];
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);
} else if (m.method) { events.push(m.method); }
};
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', 'Security.enable', 'Log.enable']) await send(m, {}, sessionId);
await send('Security.setIgnoreCertificateErrors', { ignore: true }, sessionId);
await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId);
await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId);
/* --ua=android 用安卓 Chrome 的 UA 复跑同一套门禁(壳层按视口宽度判定,与 UA 无关;
这里换 UA 是为了让"同一份 UI 在安卓浏览器下同样合格"有可复现的证据)。 */
const UA_STRINGS = {
iphone: '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',
android: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'
};
const UA = UA_STRINGS[(argv.ua || 'iphone').toLowerCase()];
if (!UA) { console.error(`unknown --ua=${argv.ua} (iphone | android)`); process.exit(2); }
await send('Network.setUserAgentOverride', { userAgent: UA }, 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, 300) };
return r.result?.value;
};
/* 真手指只有 touch:Chromium 在移动模拟下会自己从 touchEnd 合成一次 click。
再补一对 mouse 事件就是第二次 click —— 抽屉/面板会被「开了又关」,
于是 menu-open 这类断言假失败(实测踩过)。 */
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);
await sleep(260);
}
const DETECT = `(function(){
var vw=innerWidth, vh=innerHeight, MIN=${MIN_TAP};
var SEL='button,a[href],input:not([type=hidden]),select,textarea,[onclick],[role=button],[role=tab],[role=menuitem],[role=switch],[role=checkbox],[role=link],.nav-item-content,.nav-submenu-item,.settings-custom-select-trigger,.tab,.switch';
function nm(el){
var s=el.tagName.toLowerCase();
if(el.id) s+='#'+el.id;
else if(typeof el.className==='string'&&el.className.trim()){
var c=el.className.trim().split(/\\s+/).filter(function(x){return !/^is-|^active$|^has-/.test(x)});
s+='.'+(c[0]||''); if(c[1]&&/layout|sidebar|content|panel|main|nav|list|detail|grid|row|header|body|wrap|table|bar|tab/i.test(c[1])) s+='.'+c[1];
}
return s;
}
function path(el){ var p=[],n=el; for(var i=0;i<4&&n&&n.tagName;i++){p.unshift(nm(n));n=n.parentElement} return p.join('>') }
function label(el){
var t=(el.innerText||'').trim();
if(!t) t=(el.value||'').trim();
if(!t) t=el.getAttribute('aria-label')||el.getAttribute('title')||el.getAttribute('placeholder')||'';
if(!t&&el.getAttribute('data-i18n')) t=el.getAttribute('data-i18n');
if(!t){ var i=el.querySelector('i,svg,img'); t=(el.className||'')+'[icon]'; }
return String(t).replace(/\\s+/g,' ').trim().slice(0,42);
}
function shown(el){
var n=el;
while(n&&n!==document.documentElement){
var c=getComputedStyle(n);
if(c.display==='none'||c.visibility==='hidden'||parseFloat(c.opacity)===0) return false;
n=n.parentElement;
}
return true;
}
function inActivePage(el){
var p=el.closest('.page');
return !p||p.classList.contains('active');
}
function scrollAncestor(el,axis){
var a=el.parentElement;
while(a){ var c=getComputedStyle(a);
if(axis==='x'&&/auto|scroll/.test(c.overflowX)) return a;
if(axis==='y'&&/auto|scroll/.test(c.overflowY)) return a;
if(a===document.body) break; a=a.parentElement; }
return null;
}
function clipAncestor(el){
var a=el.parentElement;
while(a){ var c=getComputedStyle(a);
if(c.overflowX==='hidden'||c.overflowY==='hidden'||c.overflow==='hidden') return a;
if(a===document.body) break; a=a.parentElement; }
return null;
}
var fixedTally=function(){
var out=[];
[...document.querySelectorAll('*')].forEach(function(el){
var c=getComputedStyle(el);
if(c.position!=='fixed'&&c.position!=='sticky') return;
if(!shown(el)) return;
var r=el.getBoundingClientRect();
if(r.width<1||r.height<1) return;
if(c.pointerEvents==='none') return;
if(parseFloat(c.zIndex||0)<0&&c.zIndex!=='auto') {}
out.push({el:el,r:r,n:nm(el),z:c.zIndex});
});
return out;
}();
function overFixed(r){
for(var i=0;i<fixedTally.length;i++){
var f=fixedTally[i];
if(r.top>=f.r.top&&r.bottom<=f.r.bottom&&r.left>=f.r.left&&r.right<=f.r.right) return f.n;
}
return null;
}
var bad=[], scanned=0, chromeSeen={};
var list=[...document.querySelectorAll(SEL)];
list.forEach(function(el){
if(!shown(el)||!inActivePage(el)) return;
/* 折叠 <details> 里的控件本来就要展开才存在,不算"点不到" */
var det=el.closest('details');
if(det&&!det.open&&!det.querySelector('summary').contains(el)) return;
/*SCOPE*/
if(el.ownerSVGElement) return;
if(el.closest('.markdown-body')&&getComputedStyle(el).display==='inline') return;
scanned++;
var c=getComputedStyle(el);
var r=el.getBoundingClientRect();
/* try to bring it into view first: unreachable means a real dead button */
if(r.top<0||r.bottom>vh||r.left<0||r.right>vw){
/* 判"够不够得到"必须按手指能做的事来:逐层把可滚祖先滚到把元素居中。
scrollIntoView 不算——实测它在这种「外层 overflow:hidden + 内层 auto」的嵌套里
根本不滚(元素原地不动),会把 59 个划得到的控件误报成 offscreen。
overflow:hidden 的祖先手指也划不动,所以只滚 auto/scroll 的层。 */
(function reveal(node){
var a=node.parentElement;
while(a&&a!==document.documentElement){
var cc=getComputedStyle(a);
if(/auto|scroll/.test(cc.overflowY)&&a.scrollHeight>a.clientHeight+2){
var ab=a.getBoundingClientRect(), rb2=node.getBoundingClientRect();
a.scrollTop+= (rb2.top+rb2.height/2)-(ab.top+ab.height/2);
}
if(/auto|scroll/.test(cc.overflowX)&&a.scrollWidth>a.clientWidth+2){
var ab2=a.getBoundingClientRect(), rb3=node.getBoundingClientRect();
a.scrollLeft+= (rb3.left+rb3.width/2)-(ab2.left+ab2.width/2);
}
a=a.parentElement;
}
})(el);
r=el.getBoundingClientRect();
}
var w=r.width,h=r.height;
var out={el:nm(el),p:path(el),t:label(el),w:Math.round(w),h:Math.round(h)};
if(w<2||h<2){ out.why='zero'; bad.push(out); return; }
if(c.pointerEvents==='none'||c.pointerEvents==='hidden'){ out.why='pointer-none'; bad.push(out); return; }
if(w<MIN||h<MIN){
/* 复选/单选框的命中区常常由外层 label 提供(iOS 设置里也是整行可点):
自身 >=32 且 label 两个方向都到下限才算合格,否则照报 tiny */
var pass=false;
/* 本层对复选/单选框的口径是 30px(见 mobile.css 24.11),整行/label 才是命中区 */
if((el.type==='checkbox'||el.type==='radio')&&w>=30&&h>=30){
/* 命中区常常由 label 或整行提供(本层 24.11 就是按"整行可点"写的):
向上找第一个真正可点的祖先,它到下限就算合格 */
var n=el, d=0;
while(n&&n!==document.body&&d++<5){
var clickable=n.tagName==='LABEL'||n.hasAttribute('onclick')||getComputedStyle(n).cursor==='pointer';
if(clickable){var nb=n.getBoundingClientRect(); if(nb.width>=MIN&&nb.height>=MIN){pass=true;break;}}
n=n.parentElement;
}
}
if(!pass){ out.why='tiny'; bad.push(out); return; }
}
var cx=r.left+w/2, cy=r.top+h/2;
if(cx<0||cy<0||cx>vw||cy>vh){
var cl=clipAncestor(el), sx=scrollAncestor(el,'x'), sy=scrollAncestor(el,'y');
out.why='offscreen'; out.clip=cl?nm(cl):null; out.swipes=[sx?'x':null,sy?'y':null].filter(Boolean).join('');
bad.push(out); return;
}
var hitTop=document.elementFromPoint(cx,cy);
var ok=false, cur=hitTop;
while(cur){ if(cur===el){ok=true;break} cur=cur.parentElement; }
if(!ok){
cur=el;
while(cur&&!ok){ if(cur.contains&&hitTop&&cur.contains(hitTop)) ok=true; cur=cur.parentElement; }
}
if(!ok&&hitTop){
/* delegation: a handler on an ancestor is a legitimate target */
var a=el.parentElement, d=0;
while(a&&d++<4){ if(a.contains(hitTop)){ok=true;break} a=a.parentElement; }
}
if(!ok){
out.why='covered';
out.blocker=hitTop?nm(hitTop):'none';
out.blockerPath=hitTop?path(hitTop):'';
var of=overFixed(r); if(of) out.blockerFixed=of;
bad.push(out); return;
}
if(el.id) chromeSeen[el.id]=1;
});
var scr=document.scrollingElement||document.documentElement;
return {
vw:vw,vh:vh,scanned:scanned,bad:bad,
doc:{h:Math.round(scr.scrollHeight),clientH:scr.clientHeight,canScrollY:scr.scrollHeight>scr.clientHeight+2,
bodyOverflowY:getComputedStyle(document.body).overflowY,htmlTouch:getComputedStyle(document.documentElement).touchAction},
chrome:{tabbar:!!document.querySelector('#m-tabbar'),sheet:!!document.querySelector('#m-sheet'),
menu:!!document.querySelector('.m-menu-btn'),more:!!document.querySelector('.m-more-btn'),
chatbar:!!document.querySelector('#m-chat-bar'),scrim:!!document.querySelector('#m-scrim')},
locale:(document.documentElement.getAttribute('lang')||'')
};
})()`;
/* DETECT 默认扫"当前页 + 全局固定件"。遮罩展开时页面元素会被 scrim 判成 covered,
所以扫抽屉/面板要限定作用域,否则全是假红。 */
const detectFor = scope => DETECT.replace('/*SCOPE*/',
scope ? `if(!el.closest(${JSON.stringify(scope)})) return;` : '');
async function openPage(hash) {
await ev(`location.hash=${JSON.stringify(hash)}`);
await sleep(2600);
}
const host = new URL(BASE).hostname;
if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: host, path: '/' }, sessionId);
await send('Page.navigate', { url: BASE + '/' }, sessionId);
await sleep(3500);
/* The login card is a phone surface too — check it before authenticating. */
const login = await ev(DETECT);
if (!COOKIE) {
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(8000);
} else {
console.log('using the session cookie supplied via --cookie; no password was sent');
}
const who = await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`);
if (who !== 'none') { console.error('login did not take (overlay still displayed):', who); process.exit(3); }
const report = { login, pages: {}, chromeTaps: [] };
let grand = {};
const bump = k => { grand[k] = (grand[k] || 0) + 1; };
const log = (o, k) => { (o.bad || []).forEach(b => bump(b.why)); };
for (const pg of PAGES) {
await openPage(pg);
const r = await ev(DETECT);
if (!r || r.__err) { console.log(pg, 'DETECT ERROR', r && r.__err); continue; }
report.pages[pg] = r;
log(r);
const n = r.bad.length;
console.log(`${pg.padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(n).padStart(3)} ${n ? '' : ' OK'}`);
r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`));
const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
fs.writeFileSync(`${OUT}/${pg}.png`, Buffer.from(shot.data, 'base64'));
}
/* ---------------- overlays: 抽屉与面板展开后的按钮 ---------------- */
const OVERLAYS = [
{ name: 'sheet', route: 'dashboard', open: `CSAMobile.openSheet()`, scope: '#m-sheet' },
{ name: 'drawer', route: 'dashboard', open: `CSAMobile.openDrawer()`, scope: '#main-sidebar' },
{ name: 'chat-drawer', route: 'chat', open: `CSAMobile.openChatDrawer()`, scope: '#conversation-sidebar' },
];
for (const o of OVERLAYS) {
await openPage(o.route);
await ev(`(()=>{try{${o.open}}catch(e){}})()`);
await sleep(900); /* 等开合动画结束,动画中量到的是位移中的盒子 */
const r = await ev(detectFor(o.scope));
if (!r || r.__err) { console.log(o.name, 'DETECT ERROR', r && r.__err); continue; }
report.pages[o.name] = r;
log(r);
console.log(`${('[' + o.name + ']').padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(r.bad.length).padStart(3)} ${r.bad.length ? '' : ' OK'}`);
r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`));
await ev(`CSAMobile.closeSheet();CSAMobile.closeDrawer();CSAMobile.closeChatDrawer();`);
}
/* ---------------- chrome finger taps ---------------- */
/* 关闭要走用户真能点到的控件:抽屉展开后汉堡键被抽屉自己盖住,
再点它点的是侧栏(真实用户用抽屉内的 × 或遮罩)。每步前先复位,
否则一条失败会连锁把后面的断言全带偏。 */
const RESET = `CSAMobile.closeDrawer();CSAMobile.closeSheet();CSAMobile.closeChatDrawer();`;
const TAPS = [
{ name: 'menu-open', sel: '.m-menu-btn', pre: RESET, expect: `document.querySelector('#main-sidebar').classList.contains('m-open')` },
{ name: 'menu-close', sel: '#main-sidebar .m-close-btn', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` },
{ name: 'menu-scrim-close', sel: '#m-scrim', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` },
{ name: 'more-open', sel: '.m-more-btn', pre: RESET, expect: `document.querySelector('#m-sheet').classList.contains('m-show')` },
{ name: 'more-close', sel: '#m-sheet .m-close-btn', pre: `${RESET}CSAMobile.openSheet();`, expect: `!document.querySelector('#m-sheet').classList.contains('m-show')` },
{ name: 'tab-hitl', sel: '#m-tabbar [data-tab=hitl]', expect: `document.querySelector('#page-hitl').classList.contains('active')` },
{ name: 'tab-tasks', sel: '#m-tabbar [data-tab=tasks]', expect: `document.querySelector('#page-tasks').classList.contains('active')` },
{ name: 'tab-dashboard', sel: '#m-tabbar [data-tab=dashboard]', expect: `document.querySelector('#page-dashboard').classList.contains('active')` },
{ name: 'tab-chat', sel: '#m-tabbar [data-tab=chat]', expect: `document.querySelector('#page-chat').classList.contains('active')` },
{ name: 'chat-drawer-open', sel: '#m-chat-bar button:first-child', expect: `document.querySelector('#conversation-sidebar').classList.contains('m-open')` },
{ name: 'chat-drawer-close', sel: '#conversation-sidebar .m-close-btn', expect: `!document.querySelector('#conversation-sidebar').classList.contains('m-open')` },
];
if (CHROME) {
await openPage('chat');
for (const t of TAPS) {
if (t.pre) await ev(`(()=>{try{${t.pre}}catch(e){}})()`);
await sleep(320);
const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(t.sel)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2,w:r.width,h:r.height,shown:getComputedStyle(e).display!=='none'}})()`);
if (!box || !box.shown) { report.chromeTaps.push({ name: t.name, ok: false, why: 'absent' }); console.log(`tap ${t.name.padEnd(20)} ABSENT`); continue; }
events.length = 0;
await fingerTap(box.x, box.y);
const passed = await ev(`(()=>{try{return !!(${t.expect})}catch(e){return false}})()`);
const err = events.includes('Runtime.exceptionThrown');
report.chromeTaps.push({ name: t.name, ok: !!passed && !err, state: passed, err });
console.log(`tap ${t.name.padEnd(20)} ${passed && !err ? 'ok' : 'FAIL'} (state=${passed} jsErr=${err})`);
}
/* language round-trip: the injected chrome must re-label itself */
await ev(`window.changeLanguage && window.changeLanguage('en-US')`);
await sleep(2500);
const en = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`);
report.langEn = en;
console.log('en-US chrome labels:', JSON.stringify(en));
await ev(`window.changeLanguage && window.changeLanguage('ru-RU')`);
await sleep(2500);
const ru = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`);
report.langRu = ru;
console.log('ru-RU chrome labels:', JSON.stringify(ru));
await ev(`window.changeLanguage && window.changeLanguage('zh-CN')`);
await sleep(2000);
}
fs.writeFileSync(JSON_OUT, JSON.stringify(report, null, 2));
const chromeFail = report.chromeTaps.filter(t => !t.ok).length;
const total = Object.values(grand).reduce((a, b) => a + b, 0);
console.log(`\n=========== MOBILE TAP AUDIT @${W}x${H} ===========`);
console.log(`pages ${Object.keys(report.pages).length}`);
console.log(`covered (occluded) ${grand.covered || 0}`);
console.log(`offscreen ${grand.offscreen || 0}`);
console.log(`zero-size ${grand.zero || 0}`);
console.log(`pointer-events:none ${grand['pointer-none'] || 0}`);
console.log(`tap target < ${MIN_TAP}px ${grand.tiny || 0}`);
console.log(`chrome taps failed ${chromeFail}`);
console.log(`report ${JSON_OUT}`);
/* Ratchet: the钉死的 baseline may only go down. */
let bl = null;
try { bl = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); } catch { }
let ratchetFail = false;
if (bl) {
for (const [k, v] of Object.entries(grand)) {
if ((bl[k] ?? 0) < v) { console.log(`RATCHET FAIL ${k}: baseline ${bl[k]} -> now ${v}`); ratchetFail = true; }
}
for (const k of Object.keys(bl)) {
if (k.startsWith('page:')) {
const name = k.slice(5), now = (report.pages[name] || {}).bad?.length ?? 0;
if (bl[k] < now) { console.log(`RATCHET FAIL ${name}: baseline ${bl[k]} -> now ${now}`); ratchetFail = true; }
}
}
if (chromeFail > (bl.chromeFail ?? 0)) { console.log(`RATCHET FAIL chrome: ${bl.chromeFail} -> ${chromeFail}`); ratchetFail = true; }
} else {
const fresh = { ...grand };
for (const [p, r] of Object.entries(report.pages)) fresh['page:' + p] = r.bad.length;
fresh.chromeFail = chromeFail;
fresh.note = 'generated by scripts/mobile-tap-audit.mjs; numbers may only decrease';
fs.writeFileSync(BASELINE, JSON.stringify(fresh, null, 2));
console.log(`baseline written -> ${BASELINE}`);
}
/* 退出码口径(写死,别每次靠人判断):
- 够不到 / 被遮挡 / 零尺寸 / pointer-events:none —— 任何一处都是硬失败,控件点不到没有"存量债"可言;
- 自家注入的抽屉、面板、会话抽屉里的按钮 —— 必须 0,不许留债;
- 桌面遗留控件的小命中区 —— 走基线只降不升,超基线即红。 */
const hard = (grand.covered || 0) + (grand.offscreen || 0) + (grand.zero || 0) + (grand['pointer-none'] || 0);
const ownChrome = ['sheet', 'drawer', 'chat-drawer'].reduce((n, k) => n + ((report.pages[k] || {}).bad || []).length, 0);
const fail = hard || ownChrome || chromeFail || ratchetFail;
console.log(`\n判定: 硬缺陷 ${hard} | 自家外壳 ${ownChrome} | 点击失败 ${chromeFail} | 基线回潮 ${ratchetFail ? '是' : '否'} -> ${fail ? 'FAIL' : 'PASS'}`);
process.exit(fail ? 1 : 0);
+1
View File
@@ -0,0 +1 @@
{"tiny": 3, "page:dashboard": 0, "page:chat": 0, "page:projects": 0, "page:vulnerabilities": 0, "page:tasks": 0, "page:workflows": 0, "page:asset-overview": 0, "page:asset-library": 1, "page:info-collect": 0, "page:hitl": 0, "page:tool-guard": 2, "page:webshell": 0, "page:c2-listeners": 0, "page:c2-sessions": 0, "page:c2-tasks": 0, "page:c2-payloads": 0, "page:c2-events": 0, "page:c2-profiles": 0, "page:chat-files": 0, "page:mcp-monitor": 0, "page:mcp-management": 0, "page:knowledge-management": 0, "page:knowledge-retrieval-logs": 0, "page:roles-management": 0, "page:platform-rbac": 0, "page:skills-monitor": 0, "page:skills-management": 0, "page:agents-management": 0, "page:settings": 0, "page:audit-log": 0, "page:monitor": 0, "page:sheet": 0, "page:drawer": 0, "page:chat-drawer": 0, "chromeFail": 0, "note": "generated by scripts/mobile-tap-audit.mjs; numbers may only decrease"}
+301
View File
@@ -0,0 +1,301 @@
/*
* CyberStrikeAI — API docs page (/api-docs) on phones.
* Separate template from index.html, so it needs its own adaptation layer.
* Loaded after the template's inline <style> so equal-specificity rules win.
*/
:root {
--cs-touch: 44px;
--cs-sa-top: env(safe-area-inset-top, 0px);
--cs-sa-bottom: env(safe-area-inset-bottom, 0px);
--cs-sa-left: env(safe-area-inset-left, 0px);
--cs-sa-right: env(safe-area-inset-right, 0px);
}
@media (max-width: 768px) {
html,
body {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
.api-docs-container {
padding: 12px calc(12px + var(--cs-sa-right)) calc(24px + var(--cs-sa-bottom)) calc(12px + var(--cs-sa-left));
min-height: 100vh;
/* svh = 工具栏展开时的最小视口:写 dvh 会让页面恒定比可见区域高出一截,
最后一行内容落在 Safari 底栏后面 */
min-height: 100svh;
}
/* --- header: the desktop layout reserves 280px of right padding for the
action buttons; the title does not need to be 2rem on a phone --- */
.api-docs-header {
padding-right: 0;
padding-bottom: 64px;
margin-bottom: 20px;
}
.api-docs-header h1 {
font-size: 1.375rem;
gap: 8px;
}
.api-docs-header h1 svg {
width: 24px;
height: 24px;
}
.api-docs-header p {
font-size: .875rem;
}
/* --- two-column grid left only ~86px for the endpoint list --- */
.api-docs-content {
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
/* group nav becomes a horizontally scrollable chip row */
.api-docs-sidebar {
position: static;
width: 100%;
max-height: none;
padding: 10px 12px;
border-radius: 12px;
}
.api-docs-sidebar h3 {
margin-bottom: 8px;
font-size: .75rem;
}
.api-group-list {
display: flex;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 2px;
}
.api-group-list::-webkit-scrollbar {
display: none;
}
.api-group-item {
margin-bottom: 0;
flex: 0 0 auto;
}
.api-group-link {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 8px 14px;
white-space: nowrap;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
font-size: .875rem;
}
.api-group-link.active {
border-color: var(--accent-color);
font-weight: 600;
}
/* --- endpoint cards --- */
.api-endpoint {
margin-bottom: 16px;
border-radius: 10px;
}
.api-endpoint-header {
flex-wrap: wrap;
gap: 8px;
padding: 14px;
}
.api-endpoint-title {
flex-wrap: wrap;
min-width: 0;
gap: 8px;
}
.api-endpoint-body {
padding: 14px;
}
/* every section may hold a wide params table — let it swipe rather than clip */
.api-section,
.api-endpoint-body {
min-width: 0;
}
.api-section {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
}
.api-params-table {
/* table-layout:fixed + percentage widths + nowrap truncated every
param name to "cate…". Size columns to content and swipe instead. */
table-layout: auto;
width: auto;
min-width: 100%;
max-width: none;
font-size: .8125rem;
}
.api-params-table th,
.api-params-table td {
padding: 9px 10px;
width: auto;
min-width: 0;
overflow: visible;
text-overflow: clip;
}
.api-params-table th:nth-child(1),
.api-params-table td:nth-child(1),
.api-params-table th:nth-child(2),
.api-params-table td:nth-child(2),
.api-params-table th:nth-child(4),
.api-params-table td:nth-child(4),
.api-params-table th:nth-child(5),
.api-params-table td:nth-child(5) {
white-space: nowrap;
}
/* the long free-text column is the one worth wrapping */
.api-params-table th:nth-child(3),
.api-params-table td:nth-child(3) {
white-space: normal;
min-width: 180px;
}
/* --- code / example blocks scroll on their own --- */
.api-code-block,
.api-example-code,
.api-response-body,
pre {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
font-size: .75rem;
}
/* --- touch targets --- */
button,
.api-test-btn,
.api-toggle,
.api-example-tab,
a.api-group-link {
min-height: var(--cs-touch);
}
.api-test-btn {
width: 100%;
}
input,
select,
textarea,
.api-test-form input,
.api-test-form select,
.api-test-form textarea {
font-size: 16px; /* <16px makes iOS Safari zoom the whole page on focus */
min-height: var(--cs-touch);
}
/* --- auth accordion --- */
.api-auth-toggle,
.api-docs-auth-header {
padding: 14px;
min-height: var(--cs-touch);
}
/* --- hover-only affordances must not be the only way to see state --- */
.api-group-link:hover {
background: var(--bg-tertiary);
}
.api-endpoint-header:hover {
background: var(--bg-secondary);
}
}
@media (hover: none) and (pointer: coarse) {
.api-docs-sidebar {
scrollbar-width: thin;
}
}
/* Horizontal swipe regions: name the axis explicitly so iOS does not have to
guess, and keep the page's own vertical scroll from being hijacked. */
@media (max-width: 768px) {
.api-table-wrapper,
.api-group-list,
.api-code-block,
.api-example-code,
pre {
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
}
.api-docs-sidebar,
.api-docs-main {
min-width: 0;
}
}
/* The API docs page does not load mobile-nav.js, so the generic touch pass
that runs on the SPA never reaches these form fields and swipe regions. */
@media (max-width: 768px) {
textarea,
input,
select,
.api-test-form textarea,
.api-test-form input,
.api-test-form select,
.api-table-wrapper,
.api-group-list,
.api-code-block,
.api-example-code,
pre {
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.api-docs-header-actions button,
.api-docs-header-actions .btn-secondary,
#theme-toggle-btn,
.auth-info-header,
.api-auth-toggle {
min-height: var(--cs-touch);
display: inline-flex;
align-items: center;
}
.auth-info-header {
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px;
}
.api-test-form {
min-width: 0;
}
.api-test-form textarea {
min-height: 88px;
font-size: 16px;
}
}
File diff suppressed because it is too large. Load diff
+20
View File
@@ -4,6 +4,26 @@
"enUS": "English",
"ruRU": "Русский"
},
"mobile": {
"openNav": "Open navigation menu",
"closeNav": "Close navigation menu",
"moreActions": "More actions",
"searchMenu": "Search menu…",
"noMatch": "No matching menu items",
"backToTop": "Back to top",
"chatList": "Conversations",
"closeChatList": "Close conversation list",
"mainNav": "Main navigation",
"switchTheme": "Switch theme",
"themeNow": "Theme: {{name}}",
"switchLanguage": "Switch language",
"refreshPage": "Reload page",
"fullscreen": "Fullscreen",
"fullscreenUnsupported": "This browser does not support fullscreen",
"conversations": "Chats",
"newChat": "New chat",
"chatModuleMissing": "Chat module not loaded yet"
},
"common": {
"ok": "OK",
"cancel": "Cancel",
+20
View File
@@ -4,6 +4,26 @@
"enUS": "English",
"ruRU": "Русский"
},
"mobile": {
"openNav": "Открыть меню навигации",
"closeNav": "Закрыть меню навигации",
"moreActions": "Другие действия",
"searchMenu": "Поиск меню…",
"noMatch": "Совпадений нет",
"backToTop": "Наверх",
"chatList": "Список чатов",
"closeChatList": "Закрыть список чатов",
"mainNav": "Основная навигация",
"switchTheme": "Сменить тему",
"themeNow": "Тема: {{name}}",
"switchLanguage": "Сменить язык",
"refreshPage": "Обновить страницу",
"fullscreen": "Полный экран",
"fullscreenUnsupported": "Браузер не поддерживает полноэкранный режим",
"conversations": "Чаты",
"newChat": "Новый чат",
"chatModuleMissing": "Модуль чата ещё не загружен"
},
"common": {
"ok": "ОК",
"cancel": "Отмена",
+20
View File
@@ -4,6 +4,26 @@
"enUS": "English",
"ruRU": "Русский"
},
"mobile": {
"openNav": "打开导航菜单",
"closeNav": "关闭导航菜单",
"moreActions": "更多操作",
"searchMenu": "搜索菜单…",
"noMatch": "没有匹配的菜单",
"backToTop": "返回顶部",
"chatList": "会话列表",
"closeChatList": "关闭会话列表",
"mainNav": "主导航",
"switchTheme": "切换主题",
"themeNow": "当前主题:{{name}}",
"switchLanguage": "切换语言",
"refreshPage": "刷新页面",
"fullscreen": "全屏",
"fullscreenUnsupported": "当前浏览器不支持全屏",
"conversations": "会话",
"newChat": "新对话",
"chatModuleMissing": "对话模块尚未加载"
},
"common": {
"ok": "确定",
"cancel": "取消",
+87
View File
@@ -0,0 +1,87 @@
const fs = require('node:fs');
const test = require('node:test');
const assert = require('node:assert/strict');
/* 移动端注入层的文案键契约:mobile-ui.js 引用的每个 i18n 键必须在三种语言里都存在,
否则手机上会出现「点开关却看到 mobile.openNav」这类内部键名裸露。
遍历域 = 传播域:凡是能写出 data-i18n 的三种入口都要抽,缺一个入口就会漏检。 */
const LOCALES = ['zh-CN', 'en-US', 'ru-RU'];
const js = fs.readFileSync('web/static/js/mobile-ui.js', 'utf8');
function lookup(dict, key) {
return key.split('.').reduce((acc, part) => (acc && typeof acc === 'object' ? acc[part] : undefined), dict);
}
function referencedKeys(source) {
const patterns = [
/label\(\s*[A-Za-z_$][\w$]*\s*,\s*'([^']+)'/g,
/ariaLabel\(\s*[A-Za-z_$][\w$]*\s*,\s*'([^']+)'/g,
/setAttrI18n\(\s*[A-Za-z_$][\w$]*\s*,\s*'[^']+'\s*,\s*'([^']+)'/g,
/translate\(\s*'([^']+)'/g,
/buildSheetItem\(\s*'([^']+)'/g,
/labelKey:\s*'([^']+)'/g,
];
const keys = new Set();
for (const re of patterns) {
let m;
while ((m = re.exec(source))) keys.add(m[1]);
}
return keys;
}
const keys = referencedKeys(js);
test('至少抽到一批键,防止正则整体失配后空集通过', () => {
assert.ok(keys.size >= 16, `expected >=20 referenced keys, got ${keys.size}`);
assert.ok(keys.has('mobile.conversations'), 'tab labels must go through i18n');
});
for (const locale of LOCALES) {
test(`${locale} 覆盖移动端引用的每一个键`, () => {
const dict = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
const missing = [...keys].filter(k => typeof lookup(dict, k) !== 'string' || !lookup(dict, k).trim());
assert.deepEqual(missing, [], `${locale} 缺少可用译文: ${missing.join(', ')}`);
});
}
test('三种语言的 mobile 命名空间键集完全一致', () => {
const sets = LOCALES.map(l => Object.keys(JSON.parse(fs.readFileSync(`web/static/i18n/${l}.json`, 'utf8')).mobile).sort());
assert.deepEqual(sets[1], sets[0], 'en-US 与 zh-CN 键集不同');
assert.deepEqual(sets[2], sets[0], 'ru-RU 与 zh-CN 键集不同');
});
test('带插值的文案在三种语言里都保留占位符', () => {
for (const locale of LOCALES) {
const dict = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
assert.match(lookup(dict, 'mobile.themeNow'), /\{\{\s*name\s*\}\}/, `${locale} 的 themeNow 丢了 {{name}}`);
}
});
test('语言与主题动作复用桌面端既有键,不另立一套', () => {
assert.ok(keys.has('header.apiDocs'), 'API 文档应复用 header.apiDocs');
assert.ok(keys.has('header.github'), 'GitHub 应复用 header.github 而不是另立键');
assert.ok(keys.has('nav.dashboard'), '底部标签应复用 nav.* 分组名');
});
test('注入层不再残留会直接显示给用户的硬编码中文', () => {
/* 判据与函数名无关:helper 区之外不许出现任何"裸写用户可见文本"的 API。
helper 区 = 从文案注释块起到 toast 定义前,四件套 label/translate/setAttrI18n/ariaLabel 都在里面。 */
const from = js.indexOf('/* 注入节点的文案');
const to = js.indexOf('function toast(msg, ms)');
assert.ok(from !== -1 && to > from, '找不到文案 helper 区,说明 helper 被搬散或改名');
const outside = js.slice(0, from) + js.slice(to);
const bypasses = [
/toast\(\s*'[^']*[一-鿿][^']*'/,
/setAttribute\(\s*'aria-label'\s*,/,
/setAttribute\(\s*'placeholder'\s*,/,
/\.placeholder\s*=\s*'[^']*[一-鿿]/,
/\.textContent\s*=\s*'[^']*[一-鿿]/,
/el\(\s*'[a-zA-Z]+'\s*,\s*'[^']*'\s*,\s*'[^']*[一-鿿]/,
];
for (const re of bypasses) {
const hit = outside.match(re);
assert.equal(hit, null, `绕过 i18n 通道的写法: ${hit && hit[0]}`);
}
});
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "CyberStrikeAI",
"short_name": "CyberStrikeAI",
"description": "AI 安全测试平台 WebUI",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f8fafc",
"theme_color": "#f8fafc",
"icons": [
{ "src": "/static/logo.png", "sizes": "any", "type": "image/png", "purpose": "any" }
]
}
+5 -1
View File
@@ -2,7 +2,9 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b1120" media="(prefers-color-scheme: dark)">
<title data-i18n="apiDocs.pageTitle">API 文档 - CyberStrikeAI</title>
<link rel="icon" type="image/png" href="/static/logo.png">
<link rel="stylesheet" href="/static/css/style.css">
@@ -947,6 +949,8 @@
}
}
</style>
<!-- 移动端样式层:只覆盖 767.98px 以下,写在页内样式之后才能同特异性胜出 -->
<link rel="stylesheet" href="/static/css/api-docs-mobile.css?v=20261006-1">
</head>
<body>
<div class="api-docs-container">
+40 -6
View File
@@ -2,7 +2,15 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b1120" media="(prefers-color-scheme: dark)">
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="/static/site.webmanifest">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="CyberStrikeAI">
<title>CyberStrikeAI</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
@@ -36,6 +44,8 @@
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
<link rel="stylesheet" href="/static/css/mobile.css?v=20261007-glass-1">
<script src="/static/js/router.js?v=20260907-1"></script>
</head>
<body>
@@ -1257,7 +1267,7 @@
</div>
<div class="chat-composer-footer">
<div class="chat-composer-footer-leading">
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*" data-i18n="chat.selectFile" data-i18n-attr="title" title="选择文件">
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*/*" data-i18n="chat.selectFile" data-i18n-attr="title" title="选择文件">
<button type="button" class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" data-i18n="chat.uploadFile" data-i18n-attr="title,aria-label" data-i18n-skip-text="true" title="上传文件(可多选或拖拽到此处)" aria-label="上传文件(可多选或拖拽到此处)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
@@ -5799,10 +5809,32 @@
<script src="/static/js/sanitize-markdown.js"></script>
<!-- Cytoscape.js for attack chain visualization -->
<script src="/static/vendor/cytoscape.min.js"></script>
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->
<script src="/static/vendor/elk.bundled.js"></script>
<!-- SheetJS for XLSX export (info-collect) -->
<script src="/static/vendor/xlsx.full.min.js"></script>
<!-- ELK.js (1.5MB) + SheetJS (860KB):移动端延迟加载,首屏不再阻塞解析。
桌面端走 document.write 分支,保持与静态 <script src> 完全一致的同步语义。
安全依据:chat.js:7450 与 fact-graph.js:491 均有 typeof ELK 守卫并降级为默认布局;
assets.js:127/234/1024 与 info-collect.js:1537 均有 XLSX 守卫。 -->
<script>
(function () {
var LAZY_VENDOR = ['/static/vendor/elk.bundled.js', '/static/vendor/xlsx.full.min.js'];
var mq = window.matchMedia && window.matchMedia('(max-width: 767.98px)').matches;
if (!mq) {
for (var i = 0; i < LAZY_VENDOR.length; i++) {
document.write('<script src="' + LAZY_VENDOR[i] + '"><\/script>');
}
return;
}
var load = function () {
LAZY_VENDOR.forEach(function (src) {
var s = document.createElement('script');
s.src = src;
s.async = false;
document.head.appendChild(s);
});
};
if (window.requestIdleCallback) { requestIdleCallback(load, { timeout: 2000 }); }
else { setTimeout(load, 1200); }
})();
</script>
<script>
// 确保ELK对象全局可用
if (typeof ELK === 'undefined' && typeof elk !== 'undefined') {
@@ -6949,5 +6981,7 @@
<script src="/static/js/rbac.js"></script>
<script src="/static/js/c2.js"></script>
<script src="/static/js/rbac-guards.js?v=20260907-1"></script>
<!-- 移动端交互层:必须最后加载,它包装 window.switchPage 并注入移动端 DOM -->
<script src="/static/js/mobile-ui.js?v=20261006-9"></script>
</body>
</html>