feat: webapp topbar and tab switching
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/closeWebApp)Task 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';
|
||||
}
|
||||
@@ -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, '原数组不应被改动');
|
||||
});
|
||||
Reference in New Issue
Block a user