feat: webapp topbar and tab switching

This commit is contained in:
2026-08-16 16:34:39 +08:00
parent f8639917f3
commit 867ba3b098
5 changed files with 220 additions and 0 deletions
+95
View File
@@ -130,3 +130,98 @@ body {
border: 1px solid var(--glass-border);
color: var(--text-dim);
}
/* webAPP 顶栏:浮层玻璃条 + 标签面板 */
/* webapp topbar: floating glass bar + tab panel */
#web-topbar {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
border: 1px solid var(--glass-border);
z-index: 100;
}
#web-topbar button {
width: 30px;
height: 30px;
border: none;
border-radius: 999px;
background: none;
color: var(--text);
font-size: 16px;
cursor: pointer;
}
#web-topbar button:active { background: var(--glass-border); }
#web-topbar #tb-tabs {
width: auto;
padding: 0 12px;
font-size: 13px;
color: var(--text-dim);
}
#web-topbar .tb-title {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: var(--text);
}
#tab-panel {
position: fixed;
top: 56px;
right: 16px;
display: none;
min-width: 180px;
max-width: 260px;
padding: 8px;
border-radius: 16px;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
border: 1px solid var(--glass-border);
z-index: 100;
}
#tab-panel .tab-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
}
#tab-panel .tab-row.active { background: var(--accent); color: #fff; }
#tab-panel .tab-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
#tab-panel .tab-close {
border: none;
background: none;
color: inherit;
font-size: 16px;
cursor: pointer;
opacity: 0.7;
}
#tab-panel .tab-close:hover { opacity: 1; }
+2
View File
@@ -25,6 +25,8 @@
<script src="js/router.js"></script>
<script src="js/pages/applist.js"></script>
<script src="js/pages/webapplist.js"></script>
<script src="js/webapp/tabs.js"></script>
<script src="js/webapp/topbar.js"></script>
<script>
// 渲染侧边栏导航项 / render sidebar nav items
const nav = document.getElementById('rail-nav');
+27
View File
@@ -0,0 +1,27 @@
// webAPP 标签状态纯函数:不可变风格,Node 可 require、浏览器挂 window.tabsStore
// webapp tab state pure functions: immutable style, Node-requireable, browser exposes window.tabsStore
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.tabsStore = factory();
})(typeof self !== 'undefined' ? self : this, function () {
// 新增/激活标签:id 不存在才 push,返回激活 id 的新数组,不修改原数组
// Add/activate a tab: push only if id missing, return a new array with id active
function add(tabs, id, name) {
const base = tabs.some(t => t.id === id) ? tabs : tabs.concat({ id, name });
return base.map(t => ({ ...t, active: t.id === id }));
}
// 删除标签:过滤掉 id,返回新数组,不修改原数组
// Remove a tab: filter out id, return a new array
function remove(tabs, id) {
return tabs.filter(t => t.id !== id);
}
// 切换激活标签:返回激活 id 的新数组,不修改原数组
// Switch active tab: return a new array with id active
function switchTo(tabs, id) {
return tabs.map(t => ({ ...t, active: t.id === id }));
}
return { add, remove, switchTo };
});
@@ -0,0 +1,59 @@
// webAPP 顶栏:回退/前进/重载 + 标题 + 标签面板(切换/关闭)
// webapp topbar: back/forward/reload + title + tab panel (switch/close)
// 原生方法(webGoBack/webGoForward/webReload/switchTab/closeWebAppTask 13 才实现,
// 此处仅作为调用方,点击会静默 reject(符合预期)。
// Native methods (webGoBack/webGoForward/webReload/switchTab/closeWebApp) land in Task 13;
// this topbar is only a caller, clicks silently reject (expected).
// 展示/刷新顶栏:tabs 为标签数组(含 active 标记)
// Show/refresh the topbar: tabs is the tab array (with active flag)
window.showTopbar = function (tabs) {
let bar = document.getElementById('web-topbar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'web-topbar';
bar.innerHTML = `
<button id="tb-back" title="回退">←</button>
<button id="tb-fwd" title="前进">→</button>
<button id="tb-reload" title="重载">⟳</button>
<span class="tb-title" id="tb-title"></span>
<button id="tb-tabs"></button>`;
document.body.appendChild(bar);
document.getElementById('tb-back').onclick = () => bridge.call('webGoBack');
document.getElementById('tb-fwd').onclick = () => bridge.call('webGoForward');
document.getElementById('tb-reload').onclick = () => bridge.call('webReload');
document.getElementById('tb-tabs').onclick = () => showTabPanel(tabs);
}
document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`;
const active = tabs.find(t => t.active);
document.getElementById('tb-title').textContent = active ? active.name : '';
};
// 标签面板:列出所有标签,点击切换,× 关闭
// Tab panel: list all tabs, click to switch, × to close
function showTabPanel(tabs) {
let panel = document.getElementById('tab-panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'tab-panel';
document.body.appendChild(panel);
}
panel.innerHTML = tabs.map(t => `
<div class="tab-row ${t.active ? 'active' : ''}" data-id="${t.id}">
<span class="tab-name">${t.name}</span>
<button class="tab-close" title="关闭">×</button>
</div>`).join('');
panel.querySelectorAll('.tab-row').forEach(row => {
const id = row.dataset.id;
row.querySelector('.tab-name').onclick = () => { bridge.call('switchTab', id); closeTabPanel(); };
row.querySelector('.tab-close').onclick = () => bridge.call('closeWebApp', id);
});
panel.style.display = 'block';
}
// 收起标签面板
// Collapse the tab panel
function closeTabPanel() {
const panel = document.getElementById('tab-panel');
if (panel) panel.style.display = 'none';
}
+37
View File
@@ -0,0 +1,37 @@
// tabs.js 纯函数测试:不可变风格,验证 add/remove/switchTo
// tabs.js pure-function tests: immutable style, verify add/remove/switchTo
const { test } = require('node:test');
const assert = require('node:assert');
const { add, remove, switchTo } = require('../app/src/main/assets/h5/js/webapp/tabs.js');
test('add 不存在时新增并激活,已存在时不重复', () => {
let tabs = [];
tabs = add(tabs, 'a', '云音乐');
tabs = add(tabs, 'b', '天气');
tabs = add(tabs, 'a', '云音乐');
assert.strictEqual(tabs.length, 2, '重复 id 不应重复添加');
assert.strictEqual(tabs.filter(t => t.active).length, 1, '仅一个激活标签');
assert.strictEqual(tabs.find(t => t.active).id, 'a', '重复添加应重新激活该 id');
});
test('add 不修改传入数组(不可变)', () => {
const before = [];
const after = add(before, 'a', 'x');
assert.strictEqual(before.length, 0, '原数组不应被改动');
assert.strictEqual(after.length, 1);
});
test('remove 删除指定标签', () => {
let tabs = [{ id: 'a', name: 'x', active: false }, { id: 'b', name: 'y', active: true }];
tabs = remove(tabs, 'a');
assert.strictEqual(tabs.length, 1);
assert.strictEqual(tabs[0].id, 'b');
});
test('switchTo 激活指定 id 且不修改原数组', () => {
const before = [{ id: 'a', name: 'x', active: true }, { id: 'b', name: 'y', active: false }];
const after = switchTo(before, 'b');
assert.strictEqual(after.find(t => t.id === 'b').active, true);
assert.strictEqual(after.filter(t => t.active).length, 1);
assert.strictEqual(before.find(t => t.id === 'b').active, false, '原数组不应被改动');
});