feat(mobile): 底部TabBar溢出「更多」机制+MobileSheet底部上滑竖向菜单(2026-09-08用户要求:tab多于4个时第5格收更多,点击弹竖菜单,选中项照常open_tab进内容区)——TabBar opts.more追加更多格(派发more事件,永不高亮);TabPanel createMobileBar按max_visible(默认4)切visible/overflow,more.dataurl动态项打开时实时拉(已购产线购买即出现,无需改index.ui);新增MobileSheet控件(蒙层9500>TabBar9000<PopupWindow10000起,功能弹窗不被蒙层挡;面板justify-content:flex-end贴底,column下align-items会挤右侧;同屏单例;_teardown自实现摘除防蒙层残留;popup:true项点击直接widgetBuild urlwidget走PopupWindow自开链路);css含dark变体

This commit is contained in:
yumoqing 2026-09-08 19:30:55 +08:00
parent 44411712d0
commit 5648da13c7
3 changed files with 332 additions and 5 deletions

View File

@ -1068,6 +1068,91 @@ hr {
box-sizing: border-box;
}
/* MobileSheet 底部上滑竖向菜单2026-09-08
更多溢出 tab / 手机端功能按钮收缩共用
蒙层盖全屏含底部 TabBar 上方z-index 高于 TabBar(9000) */
.tabbar-more { flex: 0 0 auto; }
.sheet-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
/* 高于 TabBar(9000)低于 PopupWindow(app.new_zindex 10000 )
sheet 里选功能项弹出的窗口必须盖在蒙层之上可交互 */
z-index: 9500;
background: rgba(15, 23, 42, 0.45);
display: flex;
/* VBox 基底是 flex column主轴垂直面板贴底用 justify-content
align-items:flex-end column 下会把面板挤到右侧实测会翻车 */
justify-content: flex-end;
opacity: 0;
transition: opacity 0.2s ease;
}
.sheet-mask-open { opacity: 1; }
.sheet-panel {
width: 100%;
flex: 0 0 auto;
max-height: 70vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: var(--sage-bg, #ffffff);
border-radius: 16px 16px 0 0;
padding: 8px 0 calc(12px + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.18);
transform: translateY(100%);
transition: transform 0.22s ease;
box-sizing: border-box;
}
.sheet-panel-open { transform: translateY(0); }
[data-theme="dark"] .sheet-panel {
background: #0f172a;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
}
.sheet-handle {
flex: 0 0 auto;
align-self: center;
height: 4px;
border-radius: 2px;
background: #cbd5e1;
margin: 4px auto 8px;
}
[data-theme="dark"] .sheet-handle { background: #475569; }
.sheet-title {
font-size: 12px;
color: #94a3b8;
padding: 0 20px 6px;
width: 100%;
box-sizing: border-box;
}
.sheet-empty {
font-size: 13px;
color: #94a3b8;
padding: 18px 20px;
text-align: center;
width: 100%;
box-sizing: border-box;
}
.sheet-item {
min-height: 48px; /* 触控目标 >= 44px */
padding: 10px 20px !important;
box-sizing: border-box;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background 0.12s ease;
}
.sheet-item:active { background: rgba(37, 99, 235, 0.06); }
.sheet-item-active { background: rgba(37, 99, 235, 0.08); }
.sheet-item-active .sheet-item-label { color: #2563eb !important; font-weight: 600; }
[data-theme="dark"] .sheet-item:active { background: rgba(96, 165, 250, 0.10); }
[data-theme="dark"] .sheet-item-active { background: rgba(96, 165, 250, 0.12); }
[data-theme="dark"] .sheet-item-active .sheet-item-label { color: #60a5fa !important; }
.sheet-item-icon { font-size: 18px; flex: 0 0 auto; width: 26px; text-align: center; }
.sheet-item-label { font-size: 15px; color: #1e293b !important; line-height: 1.4; }
[data-theme="dark"] .sheet-item-label { color: #e2e8f0 !important; }
.sheet-item-desc { font-size: 12px; color: #94a3b8 !important; line-height: 1.3; }
/* 胶囊切换条ChipBar */
.chipbar {
gap: 8px;
@ -1112,7 +1197,13 @@ hr {
/* ══════════ 手机视口全局规则(<=768px平板按桌面处理 ══════════ */
@media (max-width: 768px) {
/* PopupWindow 全屏化bricks 弹窗行内 width/height/left/top !important 覆盖
* 只针对 .popupwindow 普通 .popup下拉菜单/小提示保持锚定小窗
* 只针对 .popupwindow 普通 .popup下拉菜单/小提示保持锚定小窗
* 但同样钳制 max-height 不许超出可视屏2026-09-08 用户报障
* 弹出窗口/控件高度超出屏幕要求高度=屏幕高
* 100vh 在手机浏览器是 large viewport含地址栏遮挡区可能大于可视高
* 支持 dvh 的内核用 dvhdynamic viewport随地址栏收缩
* 不支持的老微信 X5 popup.js _mobile_clamp window.innerHeight
* 实测内联 important 覆盖兜底JS 权威CSS 只是首帧静态兜底
* 底部让出 TabBar 高度全屏弹窗不盖住底部标签栏2026-09-08 用户要求
* 用户随时可点 Tab 离开弹窗页 */
.popupwindow {
@ -1125,6 +1216,24 @@ hr {
border-radius: 0 !important;
box-sizing: border-box;
}
/* 普通弹出控件.popup下拉/提示/锚定小窗不许高出可视屏
* :not(.popupwindow) PopupWindow 同时带两个类它的高度由上面
* .popupwindow 规则钳制要让出 TabBar别被这里覆盖 */
.popup:not(.popupwindow) {
max-width: 100vw !important;
max-height: calc(100vh - env(safe-area-inset-bottom, 0px)) !important;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@supports (height: 100dvh) {
.popupwindow {
height: calc(100dvh - 56px - env(safe-area-inset-bottom, 0px)) !important;
max-height: calc(100dvh - 56px - env(safe-area-inset-bottom, 0px)) !important;
}
.popup:not(.popupwindow) {
max-height: calc(100dvh - env(safe-area-inset-bottom, 0px)) !important;
}
}
/* 输入控件 16pxiOS Safari 对 <16px 输入框聚焦时自动放大页面 */
.inputbox, .auto-textarea, textarea,
input[type="text"], input[type="password"], input[type="number"], select {

View File

@ -3,7 +3,9 @@
// 提供:
// bricks.is_mobile() — 当前是否手机视口(<=768px平板按桌面
// bricks.on_mobile_mode(fn) — 视口模式变化监听fn(is_mobile)
// bricks.TabBar — 底部标签栏控件手机专用TabPanel mobile_bar 模式使用)
// bricks.TabBar — 底部标签栏控件手机专用TabPanel mobile_bar 模式使用;
// opts.more 时末尾追加「更多」格,点击派发 more 事件)
// bricks.MobileSheet — 底部上滑竖向菜单(「更多」溢出项/功能按钮收缩用)
// bricks.ChipBar — 横向胶囊切换条dataurl 动态数据select 事件带整条数据)
var bricks = window.bricks || {};
@ -99,6 +101,12 @@ bricks.TabBar = class extends bricks.HBox {
for (var i=0;i<this.bar_items.length;i++){
this._create_item(this.bar_items[i]);
}
// 「更多」格2026-09-08opts.more = {label, icon},固定占末位;
// 点击不走 command不是 tab派发 more 事件由宿主TabPanel弹 MobileSheet
this.more_desc = opts.more || null;
if (this.more_desc){
this._create_more(this.more_desc);
}
this._start_badge_polling(opts.badge_interval || 60);
}
_create_item(item){
@ -120,6 +128,20 @@ bricks.TabBar = class extends bricks.HBox {
this.add_widget(cell);
this.bar_btns[item.name] = {cell: cell, icon: icon, label: label, badge: badge};
}
_create_more(m){
var cell = new bricks.VBox({css:'filler tabbar-item tabbar-more', gap:'1px', alignItems:'center'});
var icon = new bricks.Text({otext: m.icon || '⋯', i18n: true, css:'tabbar-icon', cfontsize:1.3});
var label = new bricks.Text({otext: m.label || '更多', i18n: true, css:'tabbar-label'});
cell.add_widget(icon);
cell.add_widget(label);
cell.dom_element.style.cursor = 'pointer';
var self = this;
cell.bind('click', function(){ self.dispatch('more', m); });
cell.dom_element.addEventListener('touchstart', function(){ cell.set_css('tabbar-item-press'); }, {passive:true});
cell.dom_element.addEventListener('touchend', function(){ cell.set_css('tabbar-item-press', true); }, {passive:true});
this.add_widget(cell);
this.more_btn = {cell: cell, icon: icon, label: label};
}
_clicked(item){
this.set_active(item.name);
this.dispatch('command', item);
@ -131,6 +153,8 @@ bricks.TabBar = class extends bricks.HBox {
if (k === name) b.cell.set_css('tabbar-item-active');
else b.cell.set_css('tabbar-item-active', true);
}
// 「更多」格永远不高亮为选中(它不是 tab选中态属于 sheet 里的项)
if (this.more_btn) this.more_btn.cell.set_css('tabbar-item-active', true);
}
set_badge(name, n){
var b = this.bar_btns[name];
@ -176,6 +200,123 @@ bricks.TabBar = class extends bricks.HBox {
}
bricks.Factory.register('TabBar', bricks.TabBar);
/* MobileSheet
底部上滑竖向菜单2026-09-08两个场景
1. TabPanel mobile_bar 更多溢出项 5 格起收进来 dataurl 动态项
如已购产线 select 后宿主 open_tab
2. 手机端功能按钮收缩agent_menus.dspy 桌面一排按钮 一个 功能按钮
+ 本控件竖排给会话区留最大空间 item.popup=true 时点击直接
widgetBuild urlwidget服务端返回 PopupWindow 自开与桌面按钮同链路
options: {
title: 顶部小标题可空
items: [{name, label, icon(emoji), desc(灰色小字), url, popup, active_tab, _raw}]
selected: 高亮项 name
empty_text: items 为空时的提示
}
events: select item派发后自动收起
用法var sh = new bricks.MobileSheet({...}); sh.bind('select', fn); sh.open();
同屏只保留一个实例重复 open 自动收掉旧的
*/
bricks.MobileSheet = class extends bricks.VBox {
constructor(opts){
opts.width = '100%';
opts.height = '100%';
super(opts);
this.set_css('sheet-mask');
this.sheet_items = opts.items || [];
this._closed = false;
this.panel = new bricks.VBox({css:'sheet-panel', width:'100%', height:'auto'});
var handle = new bricks.VBox({css:'sheet-handle', width:'36px', height:'4px'});
this.panel.add_widget(handle);
if (opts.title){
this.panel.add_widget(new bricks.Text({text: opts.title, css:'sheet-title'}));
}
if (!this.sheet_items.length){
this.panel.add_widget(new bricks.Text({
text: opts.empty_text || '暂无内容', css:'sheet-empty'}));
}
for (var i=0;i<this.sheet_items.length;i++){
this._create_row(this.sheet_items[i], opts.selected);
}
this.add_widget(this.panel);
// 点蒙层收起;面板内点击不冒泡到蒙层
var self = this;
this.dom_element.addEventListener('click', function(e){
if (e.target === self.dom_element) self.close();
});
this.panel.dom_element.addEventListener('click', function(e){ e.stopPropagation(); });
}
_create_row(item, selected){
var row = new bricks.HBox({css:'sheet-item', width:'100%', height:'auto', gap:'12px', alignItems:'center'});
if (item.icon){
row.add_widget(new bricks.Text({text: item.icon, css:'sheet-item-icon'}));
}
var col = new bricks.VBox({css:'filler', gap:'2px'});
col.add_widget(new bricks.Text({text: item.label || item.name || '', css:'sheet-item-label'}));
if (item.desc){
col.add_widget(new bricks.Text({text: item.desc, css:'sheet-item-desc'}));
}
row.add_widget(col);
if (selected && item.name === selected){
row.set_css('sheet-item-active');
}
row.dom_element.style.cursor = 'pointer';
row.bind('click', this._row_clicked.bind(this, item));
this.panel.add_widget(row);
}
_row_clicked(item){
this.dispatch('select', item);
// item.popup + url直接构建服务端 dspy 返回 PopupWindow 自开自挂 Body
// 与桌面功能按钮 actiontype=urlwidget 同一链路;非 Popup 结果忽略)。
// 先收起 sheet 再构建PopupWindow 的 z-index 从 10000 起app.new_zindex
// sheet 蒙层 9500 —— 顺序无所谓,但收起后用户直接看到弹窗,无遮挡残影。
if (item.url && item.popup){
this.close();
try {
bricks.widgetBuild({"widgettype":"urlwidget","options":{"url":item.url}}, bricks.Body);
} catch(e){ console.log('MobileSheet popup build error', e); }
return;
}
this.close();
}
open(){
// 同屏单例:先收掉已开的 sheet
if (bricks.MobileSheet._cur && bricks.MobileSheet._cur !== this){
try { bricks.MobileSheet._cur.close(); } catch(e){}
}
bricks.MobileSheet._cur = this;
bricks.Body.add_widget(this);
// 强制回流后直接加 open 类触发过渡(不用 rAF手机浏览器后台/快速
// 点击时 rAF 可能延迟合并帧 → 蒙层开着但面板不上滑的竞态)
void this.dom_element.offsetHeight;
this.set_css('sheet-mask-open');
this.panel.set_css('sheet-panel-open');
}
close(){
if (this._closed) return;
this._closed = true;
if (bricks.MobileSheet._cur === this) bricks.MobileSheet._cur = null;
this.set_css('sheet-mask-open', true);
this.panel.set_css('sheet-panel-open', true);
var self = this;
setTimeout(function(){ self._teardown(); }, 240);
}
// 基类 Widget 没有 destroy()Popup 系才有)——自己实现摘除,
// 不能只靠 try{destroy()}(抛错后 DOM 残留 → 蒙层永远挡屏幕)
_teardown(){
try {
if (this.parent && this.parent.remove_widget){
this.parent.remove_widget(this);
} else if (this.dom_element && this.dom_element.parentNode){
this.dom_element.parentNode.removeChild(this.dom_element);
}
} catch(e){
try { this.dom_element.remove(); } catch(e2){}
}
}
}
bricks.Factory.register('MobileSheet', bricks.MobileSheet);
/* ChipBar
横向滚动胶囊切换条数据来自 dataurldspy 返回 {items:[...], selected:...}
或直接 [...]选中派发 select 事件params = 整条 item 数据

View File

@ -71,13 +71,35 @@ bricks.TabPanel = class extends bricks.Layout {
}
createMobileBar(){
var mb = this.opts.mobile_bar;
var items = ((mb && mb.items) || []).map(function(it){
var all_descs = (mb && mb.items) || [];
// 溢出规则2026-09-08 用户要求max_visible默认 4个平铺
// 超出部分收进第 N+1 格「更多」→ 点击弹 MobileSheet 竖向菜单,
// 选中项照常 open_tab 进内容区。more.dataurl 可再挂动态项
// (如已购产线,打开时实时拉取,购买后无需改 index.ui 即出现)。
var max_visible = (mb && typeof mb.max_visible === 'number' && mb.max_visible > 0)
? mb.max_visible : 4;
var visible = all_descs.slice(0, max_visible);
var overflow = all_descs.slice(max_visible);
var more_cfg = (mb && mb.more) || {};
var has_more = overflow.length > 0 || !!more_cfg.dataurl || !!more_cfg.items;
var items = visible.map(function(it){
return {name: it.name, label: it.label || it.name, icon: it.micon || '',
badge_url: it.badge_url || ''};
});
this.mobile_bar = new bricks.TabBar({items: items, badge_interval: mb.badge_interval || 60});
this._mobile_bar_descs = (mb && mb.items) || [];
var bar_opts = {items: items, badge_interval: mb.badge_interval || 60};
if (has_more){
bar_opts.more = {label: more_cfg.label || '更多', icon: more_cfg.micon || '⋯'};
}
this.mobile_bar = new bricks.TabBar(bar_opts);
// 底部栏可见 tab 的描述集合open_tab 用);溢出项也注册——
// 「更多」里选中后同样走 _mobile_bar_clicked 语义
this._mobile_bar_descs = visible.concat(overflow);
this._mobile_overflow_descs = overflow;
this._mobile_more_cfg = has_more ? more_cfg : null;
this.mobile_bar.bind('command', this._mobile_bar_clicked.bind(this));
if (has_more){
this.mobile_bar.bind('more', this._mobile_more_clicked.bind(this));
}
bricks.Body.add_widget(this.mobile_bar);
var self = this;
this._mobile_bar_apply = function(m){
@ -103,6 +125,61 @@ bricks.TabPanel = class extends bricks.Layout {
bricks.on_mobile_mode(this._mobile_bar_apply);
this._mobile_bar_apply(bricks.is_mobile());
}
// 「更多」点击:静态溢出项 + more.dataurl 动态项(打开时实时拉,
// 已购产线购买后立即出现)→ MobileSheet 竖向菜单;选中 → open_tab。
async _mobile_more_clicked(){
var cfg = this._mobile_more_cfg || {};
var items = (this._mobile_overflow_descs || []).map(function(it){
return {name: it.name, label: it.label || it.name, icon: it.micon || '',
url: it.url, _tab: true};
});
// 声明式静态附加项more.items
var extra = cfg.items || [];
for (var i=0;i<extra.length;i++){
items.push({name: extra[i].name, label: extra[i].label || extra[i].name,
icon: extra[i].micon || extra[i].icon || '', url: extra[i].url, _tab: true});
}
// 动态项(如已购产线):{items:[{name,label,icon,desc,url}]}
if (cfg.dataurl){
try {
var jc = new bricks.HttpJson();
var d = await jc.get(cfg.dataurl, {});
var dyn = (d && (d.items || d)) || [];
if (Array.isArray(dyn)){
for (var j=0;j<dyn.length;j++){
var it = dyn[j] || {};
items.push({name: it.name || ('dyn_' + j), label: it.label || it.name || '',
icon: it.icon || '', desc: it.desc || '',
url: it.url || '', popup: !!it.popup, _tab: !it.popup && !!it.url});
}
}
} catch(e){ console.log('mobile more dataurl error', e); }
}
var self = this;
var sheet = new bricks.MobileSheet({
title: cfg.title || '',
items: items,
selected: this.cur_tab_name,
empty_text: cfg.empty_text || '暂无更多功能'
});
// 注意:单个 select 监听,先注册后切换——动态项(产线页)不在
// _mobile_bar_descs 时 _mobile_bar_clicked 会查不到 desc 直接 return
// 两个监听器分先后会踩这个顺序坑2026-09-08 写完即改)
sheet.bind('select', function(ev){
var it = ev.params;
if (!it || !it._tab || !it.url) return; // popup 项已在 _row_clicked 内构建
var found = false;
for (var k=0;k<self._mobile_bar_descs.length;k++){
if (self._mobile_bar_descs[k].name === it.name){ found = true; break; }
}
if (!found){
self._mobile_bar_descs.push({name: it.name, label: it.label, url: it.url});
}
// 与平铺 tab 同语义open_tab + 底部栏高亮态更新
self._mobile_bar_clicked({name: it.name});
});
sheet.open();
}
_mobile_bar_clicked(item){
// bind 事件经 dispatch 包装为 {params: 数据}Toolbar/Menu 同约定);
// 框架内部直调传裸对象 —— 两种形态都兼容2026-09-08 实测:不解包则