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

415 lines
21 KiB
PHP

@props(['companies'])
<x-loading.menu />
<div class="container flex items-center py-3 mb-4 border-b-2 xl:hidden">
<span class="material-icons text-black js-hamburger-menu">menu</span>
<div class="flex items-center m-auto">
<img src="{{ asset('public/img/akaunting-logo-gold.svg') }}" class="w-8 m-auto" alt="Akaunting" />
<span class="ltr:ml-2 rtl:mr-2">{{ Str::limit(setting('company.name'), 22) }}</span>
</div>
@can('create-banking-transactions')
<x-dropdown id="dropdown-mobile-actions">
<x-slot name="trigger">
<span class="material-icons pointer-events-none">more_horiz</span>
</x-slot>
<x-dropdown.link href="{{ route('transactions.create', ['type' => 'income']) }}">
{{ trans('general.title.new', ['type' => trans_choice('general.incomes', 1)]) }}
</x-dropdown.link>
<x-dropdown.link href="{{ route('transactions.create', ['type' => 'expense']) }}" kind="primary">
{{ trans('general.title.new', ['type' => trans_choice('general.expenses', 1)]) }}
</x-dropdown.link>
</x-dropdown>
@endcan
</div>
@stack('menu_start')
<div
x-data="{ }"
x-init="() => {
const loadEvent = 'onpagehide' in window ? 'pageshow' : 'load';
window.addEventListener(loadEvent, () => {
$refs.realMenu.classList.remove('hidden');
});
}"
x-ref="realMenu"
class="w-70 h-screen flex hidden fixed top-0 js-menu z-20 xl:z-10 transition-all ltr:-left-80 rtl:-right-80 xl:ltr:left-0 xl:rtl:right-0"
>
<div class="finance-theme-rail w-14 py-7 px-1 bg-lilac-900 z-10 menu-scroll overflow-y-auto overflow-x-hidden">
<div
data-tooltip-target="tooltip-profile"
data-tooltip-placement="{{ language()->direction() === 'rtl' ? 'left' : 'right' }}"
class="flex flex-col items-center justify-center mb-5 cursor-pointer menu-button"
data-menu="profile-menu"
>
<span id="menu-profile-icon-cancel" name="account_circle" class="material-icons-outlined w-8 h-8 flex items-center justify-center text-purple text-2xl hidden pointer-events-none">
account_circle
</span>
@if (setting('default.use_gravatar', '0') == '1')
<img src="{{ user()->picture }}" class="w-8 h-8 m-auto rounded-full text-transparent" alt="{{ user()->name }}" title="{{ user()->name }}">
@elseif (is_object(user()->picture))
<img src="{{ route('uploads.get', user()->picture->id) }}" class="w-8 h-8 m-auto rounded-full text-transparent" alt="{{ user()->name }}" title="{{ user()->name }}">
@else
<span id="menu-profile-icon" name="account_circle" class="material-icons-outlined text-purple w-8 h-8 flex items-center justify-center text-center text-2xl pointer-events-none" title="{{ user()->name }}" aria-label="{{ user()->name }}">
account_circle
</span>
@endif
</div>
<div id="tooltip-profile" class="inline-block absolute z-20 py-1 px-2 text-sm font-medium rounded-lg bg-white text-gray-900 w-auto border border-gray-200 shadow-sm whitespace-nowrap opacity-0 invisible">
{{ trans('auth.profile') }}
<div class="absolute w-2 h-2 before:absolute before:w-2 before:h-2 before:bg-white before:border-gray-200 before:transform before:rotate-45 before:border ltr:-left-1 rtl:-right-1 before:border-t-0 ltr:before:border-r-0 rtl:before:border-l-0 border-gray-200" data-popper-arrow></div>
</div>
<div class="group flex flex-col items-center justify-center menu-toggle-buttons">
@can('read-notifications')
<x-tooltip id="tooltip-notifications" placement="{{ language()->direction() === 'rtl' ? 'left' : 'right' }}" message="{{ trans_choice('general.notifications', 2) }}">
<button type="button"
@class([
'flex items-center menu-button justify-center w-8 h-8 mb-2.5 relative cursor-pointer js-menu-toggles outline-none',
'animate-vibrate' => $notification_count,
])
data-menu="notifications-menu"
>
<span id="menu-notification-icon" name="notifications" class="material-icons-outlined text-purple text-2xl pointer-events-none">notifications</span>
@if ($notification_count)
<span data-notification-count class="w-2 h-2 absolute top-2 ltr:right-2 rtl:left-2 inline-flex items-center justify-center p-2.5 text-xs text-white font-bold leading-none transform translate-x-1/2 -translate-y-1/2 bg-orange rounded-full">
{{ $notification_count }}
</span>
@endif
</button>
</x-tooltip>
@endcan
<x-tooltip id="tooltip-search" placement="{{ language()->direction() === 'rtl' ? 'left' : 'right' }}" message="{{ trans('general.search') }}">
<button type="button" class="flex items-center menu-button justify-center w-8 h-8 mb-2.5 relative cursor-pointer outline-none">
<span id="menu-search-icon" name="search" class="material-icons-outlined text-purple text-2xl pointer-events-none">search</span>
</button>
</x-tooltip>
<x-tooltip id="tooltip-new" placement="{{ language()->direction() === 'rtl' ? 'left' : 'right' }}" message="{{ trans('general.new') }}">
<button type="button" class="add-item menu-button flex items-center justify-center w-8 h-8 mb-2.5 cursor-pointer js-menu-toggles" data-menu="add-new-menu">
<span id="menu-neww-icon" name="add_circle_outline" class="material-icons-outlined text-purple text-2xl pointer-events-none">add_circle_outline</span>
</button>
</x-tooltip>
@if (\App\Utilities\SystemAccess::canOpenSettings())
<x-tooltip id="tooltip-settings" placement="{{ language()->direction() === 'rtl' ? 'left' : 'right' }}" message="系统设置">
<button type="button" class="settings-item menu-button flex items-center justify-center w-8 h-8 mb-2.5 cursor-pointer js-menu-toggles" data-menu="settings-menu">
<span id="menu-settings-icon" name="settings" class="material-icons-outlined text-purple text-2xl pointer-events-none">settings</span>
</button>
</x-tooltip>
@endif
@unless (config('app.offline'))
<x-tooltip id="tooltip-support" placement="{{ language()->direction() === 'rtl' ? 'left' : 'right' }}" message="{{ trans('general.help') }}">
<x-link href="{{ url(trans('header.support_link')) }}" target="_blank" class="flex items-center justify-center w-8 h-8 mb-2.5 cursor-pointer js-menu-toggles" override="class">
<span id="menu-support-icon" class="material-icons-outlined text-purple text-2xl pointer-events-none">support</span>
</x-link>
</x-tooltip>
@endunless
</div>
<livewire:menu.favorites />
</div>
@stack('main_menu_start')
<nav class="finance-theme-sidebar menu-list js-main-menu" id="sidenav-main">
@stack('main_menu_company_start')
<div class="relative mb-5">
<div class="flex items-center">
<div class="w-8 h-8 flex items-center justify-center">
<img src="{{ asset('public/img/akaunting-logo-gold.svg') }}" class="w-6 h-6" alt="Akaunting" />
</div>
<div class="flex flex-col ltr:ml-2 rtl:mr-2">
<span class="w-28 ltr:text-left rtl:text-right block text-base truncate">
税务
</span>
<span class="text-xs text-gray-400">财务运营管理</span>
</div>
</div>
</div>
@stack('main_menu_admin_start')
@php
$prototypeGroups = [
['title' => '工作台', 'items' => [
['workspace', 'overview', '经营总览', 'monitoring'],
]],
['title' => '经营报表', 'items' => [
['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'],
['company-management', 'company-cost-arrangement', '公司成本安排', 'account_balance_wallet'],
['company-management', 'tax-forecast', '税金预测表', 'calculate'],
['store-management', 'store-report', '店铺维度报表', 'storefront'],
['store-management', 'internal-flow-report', '内部流转报表', 'swap_horiz'],
['company-management', 'personnel-inventory', '人员盘点表', 'groups'],
]],
['title' => '基础资料', 'items' => [
['store-management', 'basic-information', '店铺基础信息', 'shopping_bag'],
['store-management', 'store-accounts', '店铺账号密码', 'key'],
['company-management', 'basic-information', '公司基础信息', 'badge'],
['trademark-management', 'information', '商标基础信息', 'workspace_premium'],
]],
['title' => '规则信息', 'items' => [
['trademark-management', 'subject-trademark-rules', '主体商标规则', 'rule'],
['trademark-management', 'store-change-rules', '店铺变更规则', 'rule'],
['trademark-management', 'store-report-data-logic', '店铺维度报表逻辑配置', 'table_chart'],
['trademark-management', 'tax-forecast-data-logic', '税金预测表逻辑配置', 'calculate'],
]],
['title' => '业务进度', 'items' => [
['company-management', 'company-progress', '公司进度表', 'timeline'],
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'],
['workspace', 'tasks', '待办事项', 'assignment_turned_in'],
['workspace', 'warnings', '预警页面', 'warning_amber'],
]],
['title' => '财务报表', 'items' => [
['prototype-finance-reports', 'profit-report', '公司利润报表', 'request_quote'],
['prototype-finance-reports', 'asset-report', '公司资产报表', 'account_balance'],
]],
];
$visiblePages = [];
foreach (\App\Utilities\SidebarPermissions::navigationFor(user()) as $visibleSection) {
foreach ($visibleSection['pages'] ?? [] as $visiblePage) {
$visiblePages[$visibleSection['slug'] . '/' . $visiblePage['slug']] = $visiblePage['title'];
foreach ($visiblePage['children'] ?? [] as $visibleChild) {
$visiblePages[$visibleSection['slug'] . '/' . $visibleChild['slug']] = $visibleChild['title'];
}
}
}
$prototypeGroups = array_values(array_filter(array_map(function ($group) use ($visiblePages) {
$group['items'] = array_values(array_filter($group['items'], fn ($item) => isset($visiblePages[$item[0] . '/' . $item[1]])));
foreach ($group['items'] as &$item) {
$item[2] = $visiblePages[$item[0] . '/' . $item[1]];
}
unset($item);
return $group;
}, $prototypeGroups), fn ($group) => $group['items'] !== []));
@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; }
.prototype-tag-link { position: relative; display: flex; min-height: 2.4rem; align-items: center; gap: 0.65rem; margin: 0.125rem 0; padding: 0.55rem 0.75rem; border-radius: 0.25rem; color: var(--vip-sidebar-gold-soft, #DDD0AA); font-size: 0.75rem; line-height: 1rem; }
.prototype-tag-link:hover { color: var(--vip-sidebar-gold, #F0E4BF); background: rgba(240, 228, 191, 0.08); }
.prototype-tag-link.is-active { color: var(--vip-rail-gold, #F2E7C5); background: var(--vip-sidebar-active, #2A2927); }
.prototype-tag-link:focus-visible { outline: 2px solid var(--vip-sidebar-gold, #F0E4BF); outline-offset: 2px; }
.prototype-tag-link .material-icons-outlined { width: 1.25rem; flex: 0 0 1.25rem; font-size: 1rem; text-align: center; }
.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">
<div class="prototype-tag-group-title">{{ $group['title'] }}</div>
@foreach ($group['items'] as [$navSection, $navPage, $navTitle, $navIcon])
<a
href="{{ route('business.pages.show', ['section' => $navSection, 'page' => $navPage]) }}"
@class(['prototype-tag-link', 'is-active' => request()->route('section') === $navSection && request()->route('page') === $navPage])
@if (request()->route('section') === $navSection && request()->route('page') === $navPage) aria-current="page" @endif
>
<span class="material-icons-outlined" aria-hidden="true">{{ $navIcon }}</span>
<span>{{ $navTitle }}</span>
</a>
@endforeach
</section>
@endforeach
<div class="prototype-tag-empty" data-menu-filter-empty hidden>没有匹配的菜单</div>
</div>
<div class="main-menu transform">
{!! menu('admin') !!}
</div>
@stack('main_menu_admin_end')
</nav>
@stack('main_menu_end')
@stack('profile_menu_start')
<div class="finance-theme-sidebar profile-menu user-menu menu-list fixed h-full ltr:-left-80 rtl:-right-80">
@stack('profile_menu_avatar_start')
<div class="flex h-12.5">
@if (setting('default.use_gravatar', '0') == '1')
<img src="{{ user()->picture }}" class="w-8 h-8 rounded-full" alt="{{ user()->name }}" title="{{ user()->name }}">
@elseif (is_object(user()->picture))
<img src="{{ route('uploads.get', user()->picture->id) }}" class="w-8 h-8 rounded-full" alt="{{ user()->name }}" title="{{ user()->name }}">
@else
<span name="account_circle" class="material-icons-outlined w-8 h-8 flex items-center justify-center text-purple text-2xl pointer-events-none" title="{{ user()->name }}" aria-label="{{ user()->name }}">account_circle</span>
@endif
@stack('navbar_profile_welcome')
<div class="flex flex-col text-black ltr:ml-2 rtl:mr-2">
<span class="text-xs">{{ trans('general.welcome') }}</span>
{{ user()->name }}
</div>
</div>
@stack('profile_menu_profile_start')
<livewire:menu.profile />
@stack('profile_menu_profile_end')
</div>
@stack('profile_menu_end')
@can('read-notifications')
@stack('notifications_menu_start')
<div class="finance-theme-sidebar notifications-menu user-menu menu-list fixed h-full ltr:-left-80 rtl:-right-80">
@stack('notifications_menu_title_start')
<div class="flex items-center mb-3">
<span name="notifications" class="material-icons-outlined w-8 h-8 flex items-center justify-center text-purple text-2xl pointer-events-none">notifications</span>
<div class="text-black ltr:ml-1 rtl:mr-1">
{{ trans_choice('general.your_notifications', 2) }}
</div>
</div>
@stack('notifications_menu_notifications_start')
<livewire:menu.notifications />
@stack('notifications_menu_notifications_end')
</div>
@stack('notifications_menu_end')
@endcan
@stack('settings_menu_start')
<div class="finance-theme-sidebar settings-menu user-menu menu-list fixed h-full overflow-y-unset ltr:-left-80 rtl:-right-80">
@stack('settings_menu_title_start')
<div class="flex items-center mb-3">
<span name="settings" class="material-icons-outlined w-8 h-8 flex items-center justify-center text-purple text-2xl pointer-events-none">settings</span>
<div class="text-black ltr:ml-1 rtl:mr-1">
系统设置
</div>
</div>
@stack('settings_menu_settings_start')
<livewire:menu.settings />
@stack('settings_menu_settings_end')
</div>
@stack('settings_menu_end')
@stack('add_new_menu_start')
<div class="finance-theme-sidebar add-new-menu user-menu menu-list fixed h-full ltr:-left-80 rtl:-right-80">
@stack('add_new_menu_title_start')
<div class="flex items-center mb-3">
<span name="add_circle_outline" class="material-icons-outlined w-8 h-8 flex items-center justify-center text-purple text-2xl pointer-events-none">add_circle_outline</span>
<div class="text-black ltr:ml-1 rtl:mr-1">
{{ trans('general.new_more') }}
</div>
</div>
@stack('add_new_menu_add_new_start')
<livewire:menu.neww />
@stack('add_new_menu_add_new_end')
</div>
@stack('add_new_menu_end')
<button type="button" class="finance-theme-toggle toggle-button absolute ltr:-right-2 rtl:-left-2 top-8 cursor-pointer transition-opacity ease-in-out z-50">
<span class="material-icons text-lg text-purple transform ltr:rotate-90 rtl:-rotate-90 pointer-events-none">expand_circle_down</span>
</button>
<span data-menu-close id="menu-cancel" class="finance-theme-toggle material-icons absolute ltr:-right-2 rtl:-left-1.5 transition-all top-8 text-lg text-purple cursor-pointer z-10 hidden">cancel</span>
<div class="fixed w-full h-full invisible lg:hidden js-menu-background" style="background-color: rgba(0, 0, 0, 0.5); z-index: -1;"></div>
</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>