侧边栏菜单搜索 + 已打开页面标签栏

1)侧边栏菜单上方新增搜索框:按关键字过滤菜单项(如输「快递应付」直接定位),
   无匹配时给出提示,Esc 清空。
2)页面顶部新增「已打开页面」标签栏:点开过的业务页面并排固定在页面上方,
   点标签直接切回(回到最近一次带筛选的地址);同一张页面按路径去重;
   标签按账号存在浏览器本地(localStorage),没退出登录就一直保留;
   支持单个关闭和「清空(保留当前页)」,最多并排 15 个,超出横向滚动。
   钉钉单页入口模式下不显示这个标签栏。
This commit is contained in:
renshen 2026-10-13 14:57:20 +08:00
parent cdcb1c6662
commit a884da7057
5 changed files with 264 additions and 0 deletions

View File

@ -0,0 +1,177 @@
{{--
已打开页面标签栏:点开过的业务页面固定在顶部并排显示,方便来回切换;
只要没退出登录就一直保留(按账号存在浏览器本地,不外传)。
--}}
<div class="open-tabs" data-open-tabs data-user-id="{{ user()->id }}" hidden>
<div class="open-tabs__scroll">
<div class="open-tabs__list" data-open-tabs-list role="tablist" aria-label="已打开页面"></div>
</div>
<button type="button" class="open-tabs__clear" data-open-tabs-clear title="清空已打开页面(当前页面保留)">清空</button>
</div>
<style>
.open-tabs {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--vip-border, #e7e0d5);
border-radius: 0.625rem;
background: var(--vip-surface, #fff);
}
.open-tabs[hidden] { display: none; }
.open-tabs__scroll { flex: 1 1 auto; min-width: 0; overflow-x: auto; }
.open-tabs__list { display: flex; align-items: center; gap: 0.35rem; white-space: nowrap; }
.open-tabs__tab {
display: inline-flex;
align-items: center;
gap: 0.35rem;
max-width: 15rem;
padding: 0.3rem 0.5rem 0.3rem 0.65rem;
border: 1px solid var(--vip-border, #e7e0d5);
border-radius: 0.5rem;
background: #fbf8f0;
color: var(--vip-content-ink, #171717);
font-size: 0.75rem;
text-decoration: none;
}
.open-tabs__tab:hover { border-color: var(--vip-content-accent, #b9954f); color: var(--vip-content-accent-dark, #856632); }
.open-tabs__tab.is-active {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-accent-dark, #856632);
font-weight: 600;
}
.open-tabs__label { overflow: hidden; text-overflow: ellipsis; }
.open-tabs__close {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
opacity: 0.55;
}
.open-tabs__close:hover { background: rgba(133, 102, 50, 0.15); opacity: 1; }
.open-tabs__clear {
flex: 0 0 auto;
height: 1.6rem;
padding: 0 0.5rem;
border: 1px solid var(--vip-border, #e7e0d5);
border-radius: 0.5rem;
background: #fff;
color: var(--vip-content-muted, #595959);
font-size: 0.6875rem;
cursor: pointer;
}
.open-tabs__clear:hover { border-color: var(--vip-content-accent-dark, #856632); color: var(--vip-content-accent-dark, #856632); }
</style>
<script>
(() => {
const root = document.querySelector('[data-open-tabs]');
if (!root) {
return;
}
const list = root.querySelector('[data-open-tabs-list]');
const storageKey = 'caiwu.open-pages.v1.' + (root.dataset.userId || 'anon');
const maxTabs = 15;
const read = () => {
try {
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]');
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.url === 'string' && typeof item.path === 'string') : [];
} catch (_) {
return [];
}
};
const save = (items) => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(items));
} catch (_) { /* 隐私模式下忽略 */ }
};
const currentUrl = window.location.pathname + window.location.search;
const currentPath = window.location.pathname;
const isBusinessPage = /^\/\d+\/workspace\//.test(window.location.pathname);
const pageTitle = () => (document.title || '').split(' - ')[0].trim() || window.location.pathname;
let items = read();
if (isBusinessPage) {
// 同一张页面(按路径去重)只保留一个标签,点击标签回到最近一次带筛选的地址。
const existing = items.find((item) => item.path === currentPath);
if (existing) {
existing.title = pageTitle();
existing.url = currentUrl;
} else {
items.push({ path: currentPath, url: currentUrl, title: pageTitle() });
}
items = items.slice(-maxTabs);
save(items);
}
if (!items.length) {
return;
}
root.hidden = false;
const render = () => {
list.innerHTML = items.map((item) => {
const active = item.path === currentPath ? ' is-active' : '';
const title = String(item.title || item.url).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[ch]));
const href = item.url.replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[ch]));
return '<a class="open-tabs__tab' + active + '" href="' + href + '" role="tab"'
+ (active ? ' aria-current="page"' : '') + ' title="' + title + '">'
+ '<span class="open-tabs__label">' + title + '</span>'
+ '<button type="button" class="open-tabs__close" data-open-tab-close="' + href + '" aria-label="关闭 ' + title + '">×</button>'
+ '</a>';
}).join('');
};
render();
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-open-tab-close]');
if (!button) {
return;
}
event.preventDefault();
event.stopPropagation();
const url = button.getAttribute('data-open-tab-close');
const index = items.findIndex((item) => item.url === url);
const wasActive = items[index] ? items[index].path === currentPath : false;
items = items.filter((item) => item.url !== url);
save(items);
if (wasActive) {
const fallback = items[Math.max(0, index - 1)] || items[0];
if (fallback && fallback.url) {
window.location.assign(fallback.url);
return;
}
}
render();
});
root.querySelector('[data-open-tabs-clear]')?.addEventListener('click', () => {
items = items.filter((item) => item.path === currentPath);
save(items);
if (!items.length) {
root.hidden = true;
list.innerHTML = '';
return;
}
render();
});
})();
</script>

View File

@ -92,6 +92,10 @@
</x-slot>
</x-layouts.admin.header>
@unless ($focusEntry)
@include('business.pages.partials.open-page-tabs')
@endunless
<x-layouts.admin.content>
<livewire:notification.browser />

View File

@ -199,6 +199,22 @@
@endphp
<style>
.prototype-tag-filter { margin: 0 0 1rem; }
.prototype-tag-filter input {
width: 100%;
height: 2rem;
padding: 0 0.6rem;
border: 1px solid rgba(240, 228, 191, 0.25);
border-radius: 0.375rem;
background: rgba(240, 228, 191, 0.06);
color: var(--vip-sidebar-gold, #F0E4BF);
font: inherit;
font-size: 0.75rem;
outline: none;
}
.prototype-tag-filter input::placeholder { color: rgba(240, 228, 191, 0.45); }
.prototype-tag-filter input:focus { border-color: var(--vip-sidebar-gold, #F0E4BF); background: rgba(240, 228, 191, 0.12); }
.prototype-tag-empty { padding: 0.35rem 0.75rem; color: rgba(240, 228, 191, 0.5); font-size: 0.75rem; }
.prototype-tag-navigation { margin: 0 -0.25rem 1.25rem; }
.prototype-tag-group { margin-bottom: 1.15rem; }
.prototype-tag-group-title { padding: 0 0.75rem 0.45rem; color: var(--vip-sidebar-gold-soft, #DDD0AA); font-size: 0.6875rem; line-height: 1rem; }
@ -210,6 +226,10 @@
.prototype-tag-link > span:nth-child(2) { flex: 1; white-space: nowrap; }
</style>
<div class="prototype-tag-filter">
<input type="search" id="prototype-menu-filter" placeholder="搜索菜单,如:快递应付" autocomplete="off" aria-label="搜索菜单">
</div>
<div class="prototype-tag-navigation" aria-label="税务功能导航">
@foreach ($prototypeGroups as $group)
<section class="prototype-tag-group">
@ -226,6 +246,7 @@
@endforeach
</section>
@endforeach
<div class="prototype-tag-empty" data-menu-filter-empty hidden>没有匹配的菜单</div>
</div>
<div class="main-menu transform">
@ -347,3 +368,47 @@
</div>
@stack('menu_end')
<script>
// 侧边栏菜单搜索:按关键字过滤菜单项,方便快速找到页面。
(() => {
const input = document.getElementById('prototype-menu-filter');
if (!input) {
return;
}
const nav = document.querySelector('.prototype-tag-navigation');
if (!nav) {
return;
}
const groups = Array.from(nav.querySelectorAll('.prototype-tag-group'));
const empty = nav.querySelector('[data-menu-filter-empty]');
const apply = () => {
const keyword = input.value.trim().toLowerCase();
let total = 0;
groups.forEach((group) => {
let visible = 0;
group.querySelectorAll('.prototype-tag-link').forEach((link) => {
const match = keyword === '' || link.textContent.trim().toLowerCase().includes(keyword);
link.style.display = match ? '' : 'none';
if (match) {
visible += 1;
}
});
group.style.display = visible > 0 ? '' : 'none';
total += visible;
});
if (empty) {
empty.hidden = total > 0;
}
};
input.addEventListener('input', apply);
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
input.value = '';
apply();
}
});
})();
</script>

View File

@ -149,6 +149,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase
]))
->assertOk()
->assertDontSee('id="sidenav-main"', false)
->assertDontSee('data-open-tabs', false)
->assertSeeText('店铺违规信息预警');
// 同目录下的另一个页面(流程统计)在钉钉里同样是单页模式。

View File

@ -188,6 +188,23 @@ class NavigationTest extends FeatureTestCase
->assertSee('/workspace/financial-risk-control/store-violation-alert', false);
}
public function test_business_pages_render_menu_filter_and_open_page_tabs(): void
{
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
]))
->assertOk()
// 侧边栏菜单搜索框(方便按名称找页面)。
->assertSee('id="prototype-menu-filter"', false)
->assertSee('prototype-menu-filter', false)
// 已打开页面标签栏(固定在页面上方,点开的页面并排显示)。
->assertSee('data-open-tabs', false)
->assertSee('data-open-tabs-list', false)
->assertSee('caiwu.open-pages.v1.', false);
}
public function testProcessStatisticsPageIsAvailableUnderRiskControl(): void
{
$this->loginAs()