feat: webapp topbar and tab switching
This commit is contained in:
@@ -130,3 +130,98 @@ body {
|
|||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
color: var(--text-dim);
|
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/router.js"></script>
|
||||||
<script src="js/pages/applist.js"></script>
|
<script src="js/pages/applist.js"></script>
|
||||||
<script src="js/pages/webapplist.js"></script>
|
<script src="js/pages/webapplist.js"></script>
|
||||||
|
<script src="js/webapp/tabs.js"></script>
|
||||||
|
<script src="js/webapp/topbar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// 渲染侧边栏导航项 / render sidebar nav items
|
// 渲染侧边栏导航项 / render sidebar nav items
|
||||||
const nav = document.getElementById('rail-nav');
|
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