/* ==========================================================================
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: '',
close: '',
more: '',
dashboard: '',
chat: '',
hitl: '',
tasks: '',
doc: '',
github: '',
theme: '',
lang: '',
top: '',
refresh: '',
full: '', list: '',
plus: ''
};
/* 底部标签栏定义。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',
'
' +
'CyberStrikeAI');
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();
}
})();