侧边栏菜单搜索 + 已打开页面标签栏
1)侧边栏菜单上方新增搜索框:按关键字过滤菜单项(如输「快递应付」直接定位), 无匹配时给出提示,Esc 清空。 2)页面顶部新增「已打开页面」标签栏:点开过的业务页面并排固定在页面上方, 点标签直接切回(回到最近一次带筛选的地址);同一张页面按路径去重; 标签按账号存在浏览器本地(localStorage),没退出登录就一直保留; 支持单个关闭和「清空(保留当前页)」,最多并排 15 个,超出横向滚动。 钉钉单页入口模式下不显示这个标签栏。
This commit is contained in:
parent
cdcb1c6662
commit
a884da7057
177
resources/views/business/pages/partials/open-page-tabs.blade.php
Normal file
177
resources/views/business/pages/partials/open-page-tabs.blade.php
Normal 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[ch]));
|
||||
const href = item.url.replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
@ -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 />
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -149,6 +149,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase
|
||||
]))
|
||||
->assertOk()
|
||||
->assertDontSee('id="sidenav-main"', false)
|
||||
->assertDontSee('data-open-tabs', false)
|
||||
->assertSeeText('店铺违规信息预警');
|
||||
|
||||
// 同目录下的另一个页面(流程统计)在钉钉里同样是单页模式。
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user