Files
CyberStrikeAI/web/static/js/mobile-ui.js
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

1271 lines
53 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 UI Runtime
--------------------------------------------------------------------------
与 mobile.css 配套的移动端交互层。设计语言参考 QuantumNous/new-api:
- 768px 断点(matchMedia('(max-width: 767.98px)'))
- 左侧抽屉导航(rounded + backdrop-blur 遮罩 + spring 过渡)
- 底部滑出式面板(更多 / 快捷操作)
- 底部标签栏 + 待审批角标
- visualViewport 键盘避让
本文件是"纯增量"层:不修改任何既有 JS 的行为,只在其上挂钩。
桌面端(≥768px)不注入任何 DOM,也不改变既有逻辑。
========================================================================== */
(function () {
'use strict';
/* ---------------------------------------------------------------------
0. 常量与状态
--------------------------------------------------------------------- */
var MOBILE_QUERY = '(max-width: 767.98px)';
var mq = window.matchMedia ? window.matchMedia(MOBILE_QUERY) : null;
var STORAGE_TAB = 'csaiMobileTab';
var state = {
drawerOpen: false,
sheetOpen: false,
chatDrawerOpen: false,
booted: false,
hitlPending: 0
};
function isMobile() {
return mq ? mq.matches : window.innerWidth < 768;
}
/* ---------------------------------------------------------------------
1. 图标
--------------------------------------------------------------------- */
var ICON = {
menu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>',
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
more: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7.5" height="8.5" rx="1.6"/><rect x="13.5" y="3" width="7.5" height="5.5" rx="1.6"/><rect x="13.5" y="11" width="7.5" height="10" rx="1.6"/><rect x="3" y="14" width="7.5" height="7" rx="1.6"/></svg>',
chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.2 9.2 0 0 1-2.9-.4L3 21l1.5-5.1A8.3 8.3 0 0 1 3 11.5 8.4 8.4 0 0 1 12 3a8.4 8.4 0 0 1 9 8.5z"/></svg>',
hitl: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 5.5v6c0 5 3.4 9.3 8 10.5 4.6-1.2 8-5.5 8-10.5v-6L12 2z"/><path d="m9 12 2 2 4-4"/></svg>',
tasks: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2.2"/><path d="M8.5 8.5h7M8.5 12.5h7M8.5 16.5h4"/></svg>',
doc: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h6"/></svg>',
github: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.58 2 12.23c0 4.52 2.87 8.35 6.84 9.7.5.1.68-.22.68-.49v-1.7c-2.78.62-3.37-1.37-3.37-1.37-.45-1.18-1.11-1.5-1.11-1.5-.91-.63.07-.62.07-.62 1 .07 1.53 1.05 1.53 1.05.9 1.57 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.06 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.93-2.35 4.8-4.58 5.05.36.32.68.94.68 1.9v2.82c0 .27.18.6.69.49A10.06 10.06 0 0 0 22 12.23C22 6.58 17.52 2 12 2z"/></svg>',
theme: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/></svg>',
lang: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 2.5 15.4 0 18M12 3c-2.5 2.6-2.5 15.4 0 18"/></svg>',
top: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3.1-6.8"/><path d="M21 4v5h-5"/></svg>',
full: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>', list: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/></svg>',
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>'
};
/* 底部标签栏定义。label 只在 i18next 未就绪时兜底,正式文案走 labelKey。 */
var TABS = [
{ id: 'dashboard', label: '仪表盘', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ id: 'chat', label: '对话', labelKey: 'nav.chat', icon: 'chat' },
{ id: 'hitl', label: '协同', labelKey: 'nav.hitl', icon: 'hitl', badge: 'hitl' },
{ id: 'tasks', label: '任务', labelKey: 'nav.tasks', icon: 'tasks' }
];
/* 每个标签对应的"归属页"集合,用于点亮高亮 */
var TAB_GROUP = {
dashboard: ['dashboard'],
chat: ['chat'],
hitl: ['hitl'],
tasks: ['tasks']
};
/* ---------------------------------------------------------------------
2. 工具函数
--------------------------------------------------------------------- */
function $(sel, root) {
return (root || document).querySelector(sel);
}
function $$(sel, root) {
return Array.prototype.slice.call((root || document).querySelectorAll(sel));
}
function el(tag, cls, html) {
var node = document.createElement(tag);
if (cls) node.className = cls;
if (html != null) node.innerHTML = html;
return node;
}
/* 注入节点的文案:挂 data-i18n 后交给 i18n.js 的 applyTranslations 处理,
语言切换时会自动重译,不必在本层再存一份字典。
节点先写中文兜底——i18next 可能整体加载失败(离线/vendor 被拦)。
所有"中文兜底 + 键"的写入都必须经过下面这三个函数,本层其余位置不许再出现
裸的 setAttribute('aria-label', '中文'),否则防回潮测试无法区分兜底与绕过。 */
function translate(key, fallback, opts) {
if (typeof window.t === 'function') {
var s = window.t(key, opts);
if (s && s !== key) return s;
}
return fallback.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_, name) {
return opts && Object.prototype.hasOwnProperty.call(opts, name) ? String(opts[name]) : '';
});
}
function label(textNode, key, fallback) {
textNode.setAttribute('data-i18n', key);
textNode.textContent = translate(key, fallback);
return textNode;
}
function setAttrI18n(node, attr, key, fallback) {
var list = (node.getAttribute('data-i18n-attr') || '').split(',')
.map(function (s) { return s.trim(); })
.filter(function (s) { return s && s !== attr; });
list.push(attr);
node.setAttribute('data-i18n', key);
node.setAttribute('data-i18n-attr', list.join(','));
node.setAttribute(attr, translate(key, fallback));
return node;
}
/* 带子节点(图标)的元素只能翻属性,不能翻 textContent,否则图标会被覆盖 */
function ariaLabel(node, key, fallback) {
node.setAttribute('data-i18n-skip-text', 'true');
return setAttrI18n(node, 'aria-label', key, fallback);
}
function relabelAll() {
if (typeof window.applyTranslations === 'function') window.applyTranslations(document.body);
}
function toast(msg, ms) {
var box = $('#m-toast');
if (!box) {
box = el('div');
box.id = 'm-toast';
document.body.appendChild(box);
}
box.textContent = msg;
box.classList.add('m-show');
clearTimeout(toast._t);
toast._t = setTimeout(function () {
box.classList.remove('m-show');
}, ms || 1800);
}
/* ---------------------------------------------------------------------
3. 视口高度与键盘避让
--------------------------------------------------------------------- */
var baseViewportHeight = window.innerHeight || 0;
function syncViewportHeight() {
var h = window.innerHeight || document.documentElement.clientHeight;
if (h > baseViewportHeight) baseViewportHeight = h;
document.documentElement.style.setProperty('--m-app-height', h + 'px');
}
function syncKeyboard() {
var vv = window.visualViewport;
if (!vv || !isMobile()) {
document.body.classList.remove('m-keyboard');
return;
}
var overlap = baseViewportHeight - vv.height - vv.offsetTop;
var open = overlap > 120;
document.body.classList.toggle('m-keyboard', open);
}
function watchViewport() {
syncViewportHeight();
syncKeyboard();
window.addEventListener('resize', function () {
syncViewportHeight();
syncKeyboard();
});
window.addEventListener('orientationchange', function () {
setTimeout(syncViewportHeight, 220);
setTimeout(syncKeyboard, 260);
});
if (window.visualViewport) {
/* scroll 在页面滚动时逐帧触发,而 syncKeyboard 要读 vv.height 触发强制布局,
必须用 rAF 合帧,否则滚动明显掉帧 */
var raf = 0;
var onViewportChange = function () {
if (raf) return;
raf = requestAnimationFrame(function () {
raf = 0;
syncKeyboard();
});
};
window.visualViewport.addEventListener('resize', onViewportChange);
window.visualViewport.addEventListener('scroll', onViewportChange);
}
}
/* ---------------------------------------------------------------------
4. 遮罩 / 抽屉 / 面板
--------------------------------------------------------------------- */
function ensureScrim() {
var s = $('#m-scrim');
if (!s) {
s = el('div', 'm-scrim');
s.id = 'm-scrim';
s.addEventListener('click', function () {
closeDrawer();
closeSheet();
closeChatDrawer();
closeSelectionPanels();
});
document.body.appendChild(s);
}
return s;
}
function showScrim() {
ensureScrim().classList.add('m-show');
document.body.classList.add('m-locked');
}
function hideScrimIfIdle() {
if (state.drawerOpen || state.sheetOpen || state.chatDrawerOpen) return;
if (selectionPanelOpen()) return;
var s = $('#m-scrim');
if (s) s.classList.remove('m-show');
document.body.classList.remove('m-locked');
}
/* 对话页三个选择面板(项目 / 角色 / 对话模式)都是贴底的白色抽屉,正下方就是
同为白色的输入框。没有遮罩时两层白盒子直接叠在一起,用户读到的是「布局重叠」。
补一层遮罩把抽屉与输入框分开,点遮罩即关闭,也让「点空白退出」有明确落点。 */
var SELECTION_PANELS = ['#chat-project-panel', '#role-selection-panel', '#agent-mode-panel'];
function selectionPanelOpen() {
for (var i = 0; i < SELECTION_PANELS.length; i++) {
var p = $(SELECTION_PANELS[i]);
if (p && getComputedStyle(p).display !== 'none') return true;
}
return false;
}
function closeSelectionPanels() {
/* 下拉关闭过程中面板带着 translateY,关闭时一并清掉,下次打开不会残留位移 */
SELECTION_PANELS.forEach(function (sel) {
var p = $(sel);
if (p) { p.style.transform = ''; p.style.transition = ''; }
});
['closeChatProjectPanel', 'closeRoleSelectionPanel', 'closeAgentModePanel'].forEach(function (fn) {
if (typeof window[fn] === 'function') {
try { window[fn](); } catch (e) { /* 面板可能未初始化,忽略 */ }
}
});
}
var lastPanelOpen = null;
/* 面板打开前按输入条实际位置校准两件事:
--m-panel-offset = 输入条顶部到视口底 + 8px 间距 → 面板 bottom 停在输入条上方;
--m-panel-maxh = 面板最高 45vh(且不超过输入条顶-16px)→ 面板顶部留出遮罩区,
点面板外(聊天区/遮罩)仍能关闭,小屏上不会顶到视口顶让遮罩无处可点。
这样点开项目/角色/模式面板时输入框全程可见(微信输入面板行为),不再重叠。 */
function updatePanelOffset() {
var c = document.querySelector('.chat-input-container');
if (!c) return;
var top = c.getBoundingClientRect().top;
var vh = window.innerHeight || document.documentElement.clientHeight;
var root = document.documentElement.style;
root.setProperty('--m-panel-offset', Math.round(Math.max(60, vh - top + 8)) + 'px');
root.setProperty('--m-panel-maxh', Math.max(200, Math.min(Math.round(top - 16), Math.round(vh * 0.45))) + 'px');
}
function syncSelectionScrim() {
var open = selectionPanelOpen();
/* 下拉关闭时会逐帧改 transform,MutationObserver 每帧回调一次;
开合状态没变就直接返回,避免每帧重算遮罩 */
if (open === lastPanelOpen) return;
lastPanelOpen = open;
if (open) {
updatePanelOffset();
showScrim();
document.body.classList.add('m-panel-open');
} else {
document.body.classList.remove('m-panel-open');
hideScrimIfIdle();
}
}
/* 三个选择面板是贴底抽屉,按移动端习惯支持向下拖动关闭。
面板内部可能是滚动列表,只有滚到顶才接管下拉手势。 */
function enableSelectionPanelSwipe() {
SELECTION_PANELS.forEach(function (sel) {
var panel = $(sel);
if (!panel || panel.__mPanelSwipe) return;
panel.__mPanelSwipe = true;
var startY = 0;
var lastY = 0;
var tracking = false;
var dragging = false;
panel.addEventListener('touchstart', function (e) {
if (!isMobile() || e.touches.length !== 1) return;
var n = e.target;
while (n && n !== panel) {
var oy = getComputedStyle(n).overflowY;
if ((oy === 'auto' || oy === 'scroll') && n.scrollTop > 0) return;
n = n.parentElement;
}
startY = e.touches[0].clientY;
lastY = startY;
tracking = true;
dragging = false;
}, { passive: true });
panel.addEventListener('touchmove', function (e) {
if (!tracking || e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
var dy = lastY - startY;
if (!dragging && dy > 10) dragging = true;
if (dragging && dy > 0) {
panel.style.transition = 'none';
panel.style.transform = 'translateY(' + dy + 'px)';
}
}, { passive: true });
panel.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
panel.style.transition = '';
if (!dragging) return;
/* 部分环境下 touchend 的 changedTouches 为空,回退到最后一帧的坐标 */
var endY = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientY : lastY;
var dy = endY - startY;
dragging = false;
if (dy > 70) closeSelectionPanels();
else panel.style.transform = '';
}, { passive: true });
});
}
function watchSelectionPanels() {
var mo = new MutationObserver(function () { syncSelectionScrim(); });
SELECTION_PANELS.forEach(function (sel) {
var p = $(sel);
if (p) mo.observe(p, { attributes: true, attributeFilter: ['style', 'class'] });
});
enableSelectionPanelSwipe();
}
function openDrawer() {
if (!isMobile()) return;
var sidebar = $('#main-sidebar');
if (!sidebar) return;
closeSheet();
closeChatDrawer();
sidebar.classList.add('m-open');
state.drawerOpen = true;
showScrim();
var first = $('.m-drawer-search input', sidebar);
if (first) setTimeout(function () { first.focus({ preventScroll: true }); }, 320);
}
function closeDrawer() {
var sidebar = $('#main-sidebar');
if (sidebar) {
sidebar.classList.remove('m-open');
sidebar.style.transform = '';
}
state.drawerOpen = false;
hideScrimIfIdle();
}
function openChatDrawer() {
if (!isMobile()) return;
var sidebar = $('#conversation-sidebar');
if (!sidebar) return;
/* 已打开则关闭:工具条「会话」按钮必须能反悔,否则只能点空白 */
if (state.chatDrawerOpen) {
closeChatDrawer();
return;
}
closeDrawer();
closeSheet();
sidebar.classList.add('m-open');
state.chatDrawerOpen = true;
document.body.classList.add('m-chat-drawer-open');
showScrim();
}
function closeChatDrawer() {
var sidebar = $('#conversation-sidebar');
if (sidebar) {
sidebar.classList.remove('m-open');
sidebar.style.transform = '';
}
state.chatDrawerOpen = false;
document.body.classList.remove('m-chat-drawer-open');
hideScrimIfIdle();
}
function openSheet() {
if (!isMobile()) return;
closeDrawer();
var sheet = $('#m-sheet');
if (!sheet) return;
sheet.classList.add('m-show');
state.sheetOpen = true;
showScrim();
}
function closeSheet() {
var sheet = $('#m-sheet');
if (sheet) {
sheet.classList.remove('m-show');
sheet.style.transform = '';
}
state.sheetOpen = false;
hideScrimIfIdle();
}
/* ---------------------------------------------------------------------
5. 顶部应用栏注入
--------------------------------------------------------------------- */
function buildMenuButton() {
var btn = el('button', 'm-menu-btn m-mobile-only', ICON.menu);
btn.type = 'button';
ariaLabel(btn, 'mobile.openNav', '打开导航菜单');
btn.addEventListener('click', function () {
if (state.drawerOpen) closeDrawer();
else openDrawer();
});
return btn;
}
function buildMoreButton() {
var btn = el('button', 'm-more-btn m-mobile-only', ICON.more);
btn.type = 'button';
ariaLabel(btn, 'mobile.moreActions', '更多操作');
btn.addEventListener('click', function () {
if (state.sheetOpen) closeSheet();
else openSheet();
});
return btn;
}
function injectHeaderChrome() {
var content = $('header .header-content');
if (!content) return;
if (!$('.m-menu-btn', content)) {
content.insertBefore(buildMenuButton(), content.firstChild);
}
var actions = $('.header-actions', content);
if (actions && !$('.m-more-btn', actions)) {
actions.appendChild(buildMoreButton());
}
}
/* ---------------------------------------------------------------------
6. 抽屉头部 / 搜索 / 页脚
--------------------------------------------------------------------- */
function injectDrawerChrome() {
var sidebar = $('#main-sidebar');
if (!sidebar) return;
if ($('.m-drawer-head', sidebar)) return;
var head = el('div', 'm-drawer-head m-mobile-only');
var brand = el('div', 'm-drawer-brand',
'<img src="/static/logo.png" alt="CyberStrikeAI">' +
'<span class="brand-wordmark"><span class="brand-wordmark__core">CyberStrike</span><span class="brand-wordmark__ai">AI</span></span>');
var close = el('button', 'm-close-btn', ICON.close);
close.type = 'button';
ariaLabel(close, 'mobile.closeNav', '关闭导航菜单');
close.addEventListener('click', closeDrawer);
head.appendChild(brand);
head.appendChild(close);
var search = el('div', 'm-drawer-search m-mobile-only');
var input = el('input');
input.type = 'search';
setAttrI18n(input, 'placeholder', 'mobile.searchMenu', '搜索菜单…');
setAttrI18n(input, 'aria-label', 'mobile.searchMenu', '搜索菜单');
input.setAttribute('autocomplete', 'off');
input.addEventListener('input', function () {
filterNav(input.value);
});
var empty = el('div', 'm-nav-empty m-mobile-only');
label(empty, 'mobile.noMatch', '没有匹配的菜单');
empty.hidden = true;
search.appendChild(input);
search.appendChild(empty);
sidebar.insertBefore(search, sidebar.firstChild);
sidebar.insertBefore(head, sidebar.firstChild);
}
/* 会话抽屉(对话页左侧列表):移动端关闭按钮 + 选中后自动收起 */
function injectChatDrawerChrome() {
var sidebar = $('#conversation-sidebar');
if (!sidebar) return;
if ($('.m-chat-drawer-close', sidebar)) return;
var close = el('button', 'm-close-btn m-chat-drawer-close', ICON.close);
close.type = 'button';
ariaLabel(close, 'mobile.closeChatList', '关闭会话列表');
close.addEventListener('click', closeChatDrawer);
/* 顶掉桌面端「折叠」按钮的位置,避免额外占一行 */
var header = $('.conversation-sidebar-header', sidebar);
if (header) {
header.appendChild(close);
} else {
var head = el('div', 'm-chat-drawer-head m-mobile-only');
var title = el('span', 'm-chat-drawer-title');
label(title, 'mobile.chatList', '会话列表');
head.appendChild(title);
head.appendChild(close);
sidebar.insertBefore(head, sidebar.firstChild);
}
/* 点会话项 / 新建对话后自动收起,否则列表一直盖在对话上 */
sidebar.addEventListener('click', function (e) {
if (!state.chatDrawerOpen) return;
var target = e.target;
if (!target || !target.closest) return;
/* 项目分组只做筛选、分区标题只做展开,都不关闭抽屉 */
if (target.closest('.project-folder-item, .section-header, .recent-conversations-toggle, .m-close-btn')) return;
var hit = target.closest('.conversation-item, .project-conversation-item, .new-chat-btn, .project-folder-new-conversation');
if (!hit) return;
setTimeout(closeChatDrawer, 90);
});
}
/* 菜单搜索:按文本过滤 nav-item / nav-submenu-item */
function filterNav(keyword) {
var sidebar = $('#main-sidebar');
if (!sidebar) return;
var q = String(keyword || '').trim().toLowerCase();
var items = $$('.nav-item', sidebar);
items.forEach(function (item) {
item.classList.remove('m-nav-hidden');
var subs = $$('.nav-submenu-item', item);
/* 父项只按自身标题匹配,避免子项文本把父项误判为命中 */
var content = item.querySelector('.nav-item-content');
var selfText = ((content ? content.textContent : item.textContent) || '').toLowerCase();
var selfHit = !q || selfText.indexOf(q) >= 0;
var subHit = false;
subs.forEach(function (sub) {
var hit = !q || (sub.textContent || '').toLowerCase().indexOf(q) >= 0;
sub.classList.toggle('m-nav-hit', !!q && hit);
/* 父项自身命中时整组保留,避免展开后是空组 */
sub.classList.toggle('m-nav-hidden', !!q && !hit && !selfHit);
if (hit && q) subHit = true;
});
if (!q) {
item.classList.remove('m-nav-hit');
return;
}
if (selfHit || subHit) {
item.classList.remove('m-nav-hidden');
item.classList.toggle('m-nav-hit', selfHit && !subHit);
/* 命中子项时自动展开分组 */
if (subHit) item.classList.add('expanded');
} else {
item.classList.add('m-nav-hidden');
}
});
var empty = $('.m-nav-empty');
if (empty) {
var visibleCount = 0;
items.forEach(function (item) {
if (!item.classList.contains('m-nav-hidden')) visibleCount++;
});
empty.hidden = !q || visibleCount > 0;
}
/* 分区标题:整组都被隐藏时一并隐藏 */
$$('.nav-section-label', sidebar).forEach(function (label) {
var next = label.nextElementSibling;
var anyVisible = false;
while (next && !next.classList.contains('nav-section-label')) {
if (next.classList.contains('nav-item') && !next.classList.contains('m-nav-hidden')) {
anyVisible = true;
break;
}
next = next.nextElementSibling;
}
label.classList.toggle('m-nav-hidden', !!q && !anyVisible);
});
}
/* ---------------------------------------------------------------------
7. 底部标签栏
--------------------------------------------------------------------- */
function buildTabbar() {
var bar = el('nav');
bar.id = 'm-tabbar';
ariaLabel(bar, 'mobile.mainNav', '主导航');
TABS.forEach(function (tab) {
var btn = el('button', 'm-tab', ICON[tab.icon]);
btn.type = 'button';
btn.dataset.tab = tab.id;
var span = el('span');
label(span, tab.labelKey, tab.label);
btn.appendChild(span);
if (tab.badge) {
var badge = el('span', 'm-tab-badge');
badge.dataset.badgeFor = tab.badge;
badge.style.display = 'none';
btn.appendChild(badge);
}
btn.addEventListener('click', function () {
if (typeof window.switchPage === 'function') {
window.switchPage(tab.id);
}
closeDrawer();
closeSheet();
closeChatDrawer();
});
bar.appendChild(btn);
});
return bar;
}
function injectTabbar() {
if ($('#m-tabbar')) return;
var bar = buildTabbar();
bar.classList.add('m-mobile-only');
document.body.appendChild(bar);
}
function setActiveTab(pageId) {
var bar = $('#m-tabbar');
if (!bar) return;
var target = null;
Object.keys(TAB_GROUP).forEach(function (key) {
if (TAB_GROUP[key].indexOf(pageId) >= 0) target = key;
});
/* 落在四个标签之外的页面(C2 / 资产 / 设置…)不高亮任何标签:
"更多"入口只在顶栏,标签栏里不再放第二枚重复按钮 */
$$('.m-tab', bar).forEach(function (btn) {
var id = btn.dataset.tab;
var active = id === target;
btn.classList.toggle('m-active', active);
if (active) btn.setAttribute('aria-current', 'page');
else btn.removeAttribute('aria-current');
});
}
/* ---------------------------------------------------------------------
8. 底部滑出面板
--------------------------------------------------------------------- */
function buildSheetItem(key, fallbackLabel, icon, onClick, opts) {
opts = opts || {};
var btn = el('button', 'm-sheet-item', ICON[icon]);
var span = el('span');
label(span, key, fallbackLabel);
btn.appendChild(span);
btn.type = 'button';
btn.addEventListener('click', function () {
onClick();
if (!opts.keepOpen) closeSheet();
});
if (opts.id) btn.id = opts.id;
return btn;
}
/* 次要操作在桌面端本来就有按钮,移动端只是把它们收进面板。
直接触发桌面控件,避免在本层再抄一份 URL 或行为(上游的仓库地址变过)。 */
function clickDesktopControl(matcher) {
var nodes = $$('.header-actions button, .header-actions a');
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var where = (n.getAttribute('onclick') || n.getAttribute('href') || '');
if (!matcher.test(where)) continue;
n.click();
return true;
}
return false;
}
function currentThemeLabel() {
var resolved = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
return typeof window.t === 'function' ? (window.t('theme.' + resolved) || resolved) : resolved;
}
/* 面板里可选的语言:以桌面端下拉里真实存在的选项为准,上游加语言不必改本层 */
function availableLocales() {
var seen = [];
$$('#lang-dropdown .lang-option').forEach(function (o) {
var lang = o.getAttribute('data-lang');
if (!lang) return;
if (seen.some(function (x) { return x.lang === lang; })) return;
seen.push({ lang: lang, name: (o.textContent || '').trim() });
});
if (!seen.length) seen = [{ lang: 'zh-CN', name: '中文' }, { lang: 'en-US', name: 'English' }];
return seen;
}
function cycleLanguage() {
var opts = availableLocales();
var cur = String(window.uiLocale ? window.uiLocale() : (document.documentElement.getAttribute('lang') || 'zh-CN'));
var idx = 0;
opts.forEach(function (o, i) { if (o.lang === cur) idx = i; });
var next = opts[(idx + 1) % opts.length];
if (typeof window.onLanguageSelect === 'function') {
window.onLanguageSelect(next.lang);
toast(next.name);
}
}
function requestFullscreenSafe() {
var root = document.documentElement;
if (document.fullscreenElement || document.webkitFullscreenElement) {
var exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) { try { exit.call(document); } catch (e) { /* 已退出 */ } }
return;
}
var req = root.requestFullscreen || root.webkitRequestFullscreen;
if (!req) {
/* iOS Safari 没有全屏 API:过去这里走一个空函数分支,按钮点了没有任何反馈 */
toast(translate('mobile.fullscreenUnsupported', '当前浏览器不支持全屏'));
return;
}
try {
var r = req.call(root);
if (r && typeof r.catch === 'function') {
r.catch(function () { toast(translate('mobile.fullscreenUnsupported', '当前浏览器不支持全屏')); });
}
} catch (e) {
toast(translate('mobile.fullscreenUnsupported', '当前浏览器不支持全屏'));
}
}
function injectSheet() {
if ($('#m-sheet')) return;
var sheet = el('div');
sheet.id = 'm-sheet';
sheet.setAttribute('role', 'dialog');
sheet.setAttribute('aria-modal', 'true');
ariaLabel(sheet, 'mobile.moreActions', '更多操作');
sheet.classList.add('m-mobile-only');
var grip = el('div', 'm-sheet-grip');
var head = el('div', 'm-sheet-head');
var title = el('span', 'm-sheet-title');
label(title, 'mobile.moreActions', '更多操作');
head.appendChild(title);
var close = el('button', 'm-close-btn', ICON.close);
close.type = 'button';
ariaLabel(close, 'common.close', '关闭');
close.addEventListener('click', closeSheet);
head.appendChild(close);
var body = el('div', 'm-sheet-body');
body.appendChild(buildSheetItem('header.apiDocs', 'API 文档', 'doc', function () {
if (!clickDesktopControl(/\/api-docs/)) window.location.href = '/api-docs';
}));
body.appendChild(buildSheetItem('header.github', 'GitHub', 'github', function () {
clickDesktopControl(/github\.com/i);
}));
body.appendChild(buildSheetItem('mobile.switchTheme', '切换主题', 'theme', function () {
if (typeof window.cycleThemePreference === 'function') {
window.cycleThemePreference();
} else {
var t = document.getElementById('theme-toggle-btn');
if (t) t.click();
}
toast(translate('mobile.themeNow', '当前主题:{{name}}', { name: currentThemeLabel() }));
}, { keepOpen: true }));
body.appendChild(buildSheetItem('mobile.switchLanguage', '切换语言', 'lang', cycleLanguage, { keepOpen: true }));
body.appendChild(el('div', 'm-sheet-sep'));
body.appendChild(buildSheetItem('mobile.backToTop', '返回顶部', 'top', scrollActiveToTop, { keepOpen: true }));
body.appendChild(buildSheetItem('mobile.refreshPage', '刷新页面', 'refresh', function () {
location.reload();
}));
body.appendChild(buildSheetItem('mobile.fullscreen', '全屏', 'full', requestFullscreenSafe, { keepOpen: true }));
sheet.appendChild(grip);
sheet.appendChild(head);
sheet.appendChild(body);
document.body.appendChild(sheet);
enableSheetSwipe(sheet);
}
function scrollActiveToTop() {
var page = $('.page.active .page-content') || $('.page.active');
if (page) page.scrollTo({ top: 0, behavior: 'smooth' });
var msgs = $('#chat-messages');
if (msgs && $('#page-chat') && $('#page-chat').classList.contains('active')) {
msgs.scrollTo({ top: 0, behavior: 'smooth' });
}
}
/* ---------------------------------------------------------------------
9. 对话页移动端工具条
--------------------------------------------------------------------- */
function injectChatBar() {
var container = $('.chat-container');
if (!container) return;
if ($('#m-chat-bar', container)) return;
var bar = el('div', 'm-chat-bar m-mobile-only');
bar.id = 'm-chat-bar';
var listBtn = el('button', 'm-chat-bar-btn', ICON.list);
var listSpan = el('span');
label(listSpan, 'mobile.conversations', '会话');
listBtn.appendChild(listSpan);
listBtn.type = 'button';
listBtn.addEventListener('click', openChatDrawer);
var spacer = el('div', 'm-chat-bar-spacer');
var newBtn = el('button', 'm-chat-bar-btn m-primary', ICON.plus);
var newSpan = el('span');
label(newSpan, 'mobile.newChat', '新对话');
newBtn.appendChild(newSpan);
newBtn.type = 'button';
newBtn.setAttribute('data-require-permission', 'chat:write');
newBtn.addEventListener('click', function () {
if (typeof window.startNewConversation === 'function') {
window.startNewConversation();
} else {
toast(translate('mobile.chatModuleMissing', '对话模块尚未加载'));
}
});
bar.appendChild(listBtn);
bar.appendChild(spacer);
bar.appendChild(newBtn);
container.insertBefore(bar, container.firstChild);
}
/* ---------------------------------------------------------------------
10. 表格横向滚动包装
--------------------------------------------------------------------- */
function wrapTables(root) {
if (!isMobile()) return;
var scope = root || document;
var tables = $$('table', scope);
tables.forEach(function (table) {
var parent = table.parentElement;
if (!parent) return;
if (parent.classList.contains('m-table-scroll')) return;
if (table.closest('.m-no-wrap')) return;
var cols = table.querySelectorAll('thead th').length || table.querySelectorAll('tr:first-child td').length;
var wrap = el('div', 'm-table-scroll' + (cols > 0 && cols <= 3 ? ' m-tight' : ''));
parent.insertBefore(wrap, table);
wrap.appendChild(table);
});
}
/* ---------------------------------------------------------------------
11. 路由挂钩
--------------------------------------------------------------------- */
function patchRouter() {
if (typeof window.switchPage !== 'function' || window.switchPage.__mPatched) return;
var original = window.switchPage;
var patched = function (pageId) {
var result = original.apply(this, arguments);
try {
onPageChanged(pageId);
} catch (e) {
/* 移动层异常不影响主流程 */
console.warn('[mobile-ui] onPageChanged failed', e);
}
return result;
};
patched.__mPatched = true;
patched.__mOriginal = original;
window.switchPage = patched;
}
function onPageChanged(pageId) {
setActiveTab(pageId);
closeDrawer();
closeSheet();
if (pageId !== 'chat') closeChatDrawer();
try {
localStorage.setItem(STORAGE_TAB, pageId);
} catch (e) { /* 隐私模式下忽略 */ }
/* 页面渲染是异步的,稍后再做表格包装 */
[80, 400, 1200].forEach(function (delay) {
setTimeout(function () {
var page = document.getElementById('page-' + pageId);
if (page) wrapTables(page);
}, delay);
});
}
/* ---------------------------------------------------------------------
12. 待审批角标(HITL)
--------------------------------------------------------------------- */
function setHitlBadge(count) {
state.hitlPending = count;
var badge = $('[data-badge-for="hitl"]');
if (!badge) return;
if (!count || count <= 0) {
badge.style.display = 'none';
badge.textContent = '';
return;
}
badge.textContent = count > 99 ? '99+' : String(count);
badge.style.display = 'inline-block';
}
function refreshHitlBadge() {
if (!isMobile() || !state.booted) return;
if ($('#login-overlay') && $('#login-overlay').style.display !== 'none') {
setHitlBadge(0);
return;
}
var pending = fetch('/api/hitl/pending?page=1&pageSize=1', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return 0;
if (typeof d.total === 'number') return d.total;
if (Array.isArray(d.items)) return d.items.length;
return 0;
})
.catch(function () { return 0; });
var workflows = fetch('/api/workflows/runs/pending', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !Array.isArray(d.runs)) return 0;
return d.runs.length;
})
.catch(function () { return 0; });
Promise.all([pending, workflows]).then(function (res) {
setHitlBadge((res[0] || 0) + (res[1] || 0));
});
}
/* ---------------------------------------------------------------------
13. 登录态与底部栏可见性
--------------------------------------------------------------------- */
function syncAuthChrome() {
var overlay = $('#login-overlay');
var loggedOut = overlay && overlay.style.display !== 'none';
var bar = $('#m-tabbar');
if (bar) bar.style.display = loggedOut ? 'none' : '';
if (loggedOut) {
closeDrawer();
closeSheet();
closeChatDrawer();
}
}
function watchAuthOverlay() {
var overlay = $('#login-overlay');
if (!overlay) return;
new MutationObserver(syncAuthChrome).observe(overlay, {
attributes: true,
attributeFilter: ['style', 'class']
});
}
/* ---------------------------------------------------------------------
14. 手势
--------------------------------------------------------------------- */
function enableDrawerSwipe(sidebar, closer) {
if (!sidebar) return;
var startX = 0;
var startY = 0;
var tracking = false;
var dragging = false;
sidebar.addEventListener('touchstart', function (e) {
if (!isMobile() || e.touches.length !== 1) return;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
tracking = true;
dragging = false;
}, { passive: true });
sidebar.addEventListener('touchmove', function (e) {
if (!tracking || e.touches.length !== 1) return;
var dx = e.touches[0].clientX - startX;
var dy = e.touches[0].clientY - startY;
if (!dragging) {
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.4 && dx < 0) {
dragging = true;
} else if (Math.abs(dy) > 12) {
tracking = false;
return;
}
}
if (dragging && dx < 0) {
sidebar.style.transition = 'none';
sidebar.style.transform = 'translateX(' + dx + 'px)';
}
}, { passive: true });
sidebar.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
sidebar.style.transition = '';
if (!dragging) return;
var dx = (e.changedTouches[0] ? e.changedTouches[0].clientX : startX) - startX;
dragging = false;
if (dx < -60) {
closer();
} else {
sidebar.style.transform = '';
}
}, { passive: true });
}
function enableSheetSwipe(sheet) {
var startY = 0;
var lastY = 0;
var tracking = false;
var dragging = false;
sheet.addEventListener('touchstart', function (e) {
if (!isMobile() || e.touches.length !== 1) return;
/* body 已滚动时优先滚列表;滚到顶或按在把手/头部时允许下拉关闭 */
var body = e.target.closest('.m-sheet-body');
if (body && body.scrollTop > 0) return;
startY = e.touches[0].clientY;
lastY = startY;
tracking = true;
dragging = false;
}, { passive: true });
sheet.addEventListener('touchmove', function (e) {
if (!tracking || e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
var dy = lastY - startY;
if (!dragging && dy > 10) dragging = true;
if (dragging && dy > 0) {
sheet.style.transition = 'none';
sheet.style.transform = 'translateY(' + dy + 'px)';
}
}, { passive: true });
sheet.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
sheet.style.transition = '';
if (!dragging) return;
/* 部分环境下 touchend 的 changedTouches 为空,回退到最后一帧的坐标 */
var endY = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientY : lastY;
var dy = endY - startY;
dragging = false;
if (dy > 70) closeSheet();
else sheet.style.transform = '';
}, { passive: true });
}
/* 从屏幕左缘右滑打开抽屉 */
function enableEdgeSwipe() {
var startX = 0;
var startY = 0;
var armed = false;
var dragging = false;
document.addEventListener('touchstart', function (e) {
if (!isMobile() || e.touches.length !== 1) return;
if (state.drawerOpen || state.sheetOpen || state.chatDrawerOpen) return;
var t = e.touches[0];
if (t.clientX > 24) return;
if ($('#login-overlay') && $('#login-overlay').style.display !== 'none') return;
startX = t.clientX;
startY = t.clientY;
armed = true;
dragging = false;
}, { passive: true });
document.addEventListener('touchmove', function (e) {
if (!armed || e.touches.length !== 1) return;
var dx = e.touches[0].clientX - startX;
var dy = e.touches[0].clientY - startY;
if (!dragging) {
if (dx > 18 && Math.abs(dx) > Math.abs(dy) * 1.4) {
dragging = true;
openDrawer();
} else if (Math.abs(dy) > 16) {
armed = false;
}
}
}, { passive: true });
document.addEventListener('touchend', function () {
armed = false;
dragging = false;
}, { passive: true });
}
/* 双击缩放由 CSS touch-action: manipulation 抑制(见 mobile.css),
原先的全局非 passive touchend 监听会阻塞滚动,已移除 */
/* ---------------------------------------------------------------------
15. 导航点击后自动收起抽屉
--------------------------------------------------------------------- */
function bindNavAutoClose() {
var sidebar = $('#main-sidebar');
if (!sidebar) return;
sidebar.addEventListener('click', function (e) {
if (!isMobile()) return;
var target = e.target.closest('.nav-submenu-item');
if (target && target.dataset.page) {
setTimeout(closeDrawer, 60);
return;
}
var item = e.target.closest('.nav-item-content');
if (item && item.closest('.nav-item') && !item.closest('.nav-item-has-submenu')) {
setTimeout(closeDrawer, 60);
}
});
}
/* ---------------------------------------------------------------------
16. 表格 MutationObserver
--------------------------------------------------------------------- */
function watchDom() {
var area = $('.content-area');
if (!area) return;
var timer = null;
var observer = new MutationObserver(function (records) {
/* 只关心可能引入表格的变更:聊天流式输出会持续改动 DOM,
若每次变动都全页扫表,长会话下会明显掉帧 */
var relevant = records.some(function (r) {
if (!r.addedNodes || !r.addedNodes.length) return false;
for (var i = 0; i < r.addedNodes.length; i++) {
var n = r.addedNodes[i];
if (n.nodeType !== 1) continue;
if (n.tagName === 'TABLE') return true;
if (n.querySelector && n.querySelector('table')) return true;
}
return false;
});
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(function () {
var page = $('.page.active');
if (page) wrapTables(page);
}, 260);
});
observer.observe(area, { childList: true, subtree: true });
}
/* ---------------------------------------------------------------------
17. 断点切换
--------------------------------------------------------------------- */
function onBreakpointChange() {
if (isMobile()) {
boot();
} else {
closeDrawer();
closeSheet();
closeChatDrawer();
closeSelectionPanels();
var scrim = $('#m-scrim');
if (scrim) scrim.classList.remove('m-show');
document.body.classList.remove('m-locked', 'm-keyboard');
document.documentElement.style.removeProperty('--m-app-height');
}
}
/* ---------------------------------------------------------------------
18. 启动
--------------------------------------------------------------------- */
function boot() {
if (!isMobile()) return;
injectHeaderChrome();
injectDrawerChrome();
injectChatDrawerChrome();
injectTabbar();
injectSheet();
injectChatBar();
ensureScrim();
watchAuthOverlay();
syncAuthChrome();
patchRouter();
/* 恢复上次所在页面高亮 */
var hashPage = (window.location.hash || '').replace(/^#/, '').split('?')[0];
setActiveTab(hashPage || 'dashboard');
if (!state.booted) {
state.booted = true;
bindNavAutoClose();
enableDrawerSwipe($('#main-sidebar'), closeDrawer);
enableDrawerSwipe($('#conversation-sidebar'), closeChatDrawer);
enableEdgeSwipe();
watchDom();
watchSelectionPanels();
/* 待审批角标:首次 + 每 45s + 回到前台时刷新 */
refreshHitlBadge();
setInterval(refreshHitlBadge, 45000);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) refreshHitlBadge();
});
}
wrapTables($('.page.active') || document);
/* 注入节点晚于 i18n 首帧时不会被自动翻译,这里补一次;语言切换由
i18n.js 的 applyTranslations(document) 覆盖(这些节点带着 data-i18n)。 */
relabelAll();
/* 新对话按钮带 data-require-permission:RBAC 的点击守卫是文档级委托,
但"无权限就隐藏"依赖一次显式刷新,否则低权限账号会看到点不动的按钮 */
if (typeof window.applyRBACToUI === 'function') {
try { window.applyRBACToUI(); } catch (e) { /* 未初始化时忽略 */ }
}
}
/* 手机端有遮罩兜底,平板竖屏没有移动端外壳(boot 在非移动端直接返回),
三个选择面板同样是贴底抽屉,这里用 document 级委托补上「点面板外关闭」,
让 768–1023.98px 也有一条不依赖 × 按钮的退路。 */
function bindSelectionOutsideClose() {
document.addEventListener('click', function (e) {
if (!selectionPanelOpen()) return;
if (e.target.closest('#chat-project-panel, #role-selection-panel, #agent-mode-panel')) return;
if (e.target.closest('#chat-project-btn, #role-selector-btn, #agent-mode-btn')) return;
closeSelectionPanels();
}, true);
}
function start() {
watchViewport();
if (mq) {
if (mq.addEventListener) mq.addEventListener('change', onBreakpointChange);
else if (mq.addListener) mq.addListener(onBreakpointChange);
}
if (isMobile()) {
boot();
} else {
/* 平板竖屏:面板仍贴底(mobile.css 区块 22),但 watchSelectionPanels 只在 boot 内注册 */
watchSelectionPanels();
}
bindSelectionOutsideClose();
}
/* 对外暴露,便于调试与后续扩展 */
window.CSAMobile = {
isMobile: isMobile,
openDrawer: openDrawer,
closeDrawer: closeDrawer,
openChatDrawer: openChatDrawer,
closeChatDrawer: closeChatDrawer,
openSheet: openSheet,
closeSheet: closeSheet,
closeSelectionPanels: closeSelectionPanels,
refreshHitlBadge: refreshHitlBadge,
wrapTables: wrapTables,
toast: toast
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();