登录后进入首页导航,不再直接打开第一个业务页面

- 新增首页导航页:常用分组沿用侧边栏分组,下面是全部业务模块(可展开)
- Pages@home 由跳转首个业务页改为渲染首页;侧边栏分组定义抽到 SidebarMenu 复用
- 侧边栏顶部标识可点回首页
- 更新钉钉登录落地断言,补充首页导航测试
This commit is contained in:
renshen 2026-10-19 10:42:10 +08:00
parent 09b4ffcb8a
commit 9d8133cc73
6 changed files with 419 additions and 77 deletions

View File

@ -664,24 +664,18 @@ class Pages extends Controller
public function home()
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
$groups = \App\Utilities\SidebarMenu::groups(user());
$sections = \App\Utilities\SidebarMenu::sections(user());
if (empty($section)) {
if ($groups === [] && $sections === []) {
return view('business.access-pending');
}
$page = collect($section['pages'] ?? [])->first();
$parameters = ['section' => $section['slug']];
if ($page) {
$parameters['page'] = $page['slug'];
if (! empty($page['tabs'])) {
$parameters['tab'] = $page['tabs'][0]['slug'];
}
}
return redirect()->route('business.pages.show', $parameters);
// 登录后先落到首页导航,不再直接跳进第一个业务页面。
return view('business.pages.home', [
'groups' => $groups,
'sections' => $sections,
]);
}
public function show(string $section, ?string $page = null, ?string $tab = null)

View File

@ -0,0 +1,139 @@
<?php
namespace App\Utilities;
use App\Models\Auth\User;
class SidebarMenu
{
/**
* 侧边栏与首页共用的功能分组,按当前账号可访问的页面过滤,标题跟随后台自定义名称。
*
* @return array<int, array{title: string, items: array<int, array{section: string, page: string, title: string, icon: string}>}>
*/
public static function groups(?User $user = null): array
{
$user ??= user();
$groups = [
['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 = self::visiblePages($user);
$groups = array_map(function (array $group) use ($visiblePages): array {
$items = [];
foreach ($group['items'] as [$section, $page, $title, $icon]) {
$key = $section . '/' . $page;
if (! isset($visiblePages[$key])) {
continue;
}
$items[] = [
'section' => $section,
'page' => $page,
'title' => $visiblePages[$key],
'icon' => $icon,
];
}
$group['items'] = $items;
return $group;
}, $groups);
return array_values(array_filter($groups, static fn (array $group): bool => $group['items'] !== []));
}
/**
* 当前账号可访问的全部业务模块及页面,供首页做完整导航。
*
* @return array<int, array{slug: string, title: string, icon: string, pages: array<int, array{slug: string, title: string}>}>
*/
public static function sections(?User $user = null): array
{
$user ??= user();
$sections = [];
foreach (SidebarPermissions::navigationFor($user) as $section) {
$pages = [];
foreach ($section['pages'] ?? [] as $page) {
$pages[] = ['slug' => $page['slug'], 'title' => $page['title']];
foreach ($page['children'] ?? [] as $child) {
$pages[] = ['slug' => $child['slug'], 'title' => $child['title']];
}
}
if ($pages === []) {
continue;
}
$sections[] = [
'slug' => $section['slug'],
'title' => $section['title'],
'icon' => $section['icon'] ?? 'folder',
'pages' => $pages,
];
}
return $sections;
}
/**
* @return array<string, string> 「section/page」 => 页面标题
*/
private static function visiblePages(User $user): array
{
$pages = [];
foreach (SidebarPermissions::navigationFor($user) as $section) {
foreach ($section['pages'] ?? [] as $page) {
$pages[$section['slug'] . '/' . $page['slug']] = $page['title'];
foreach ($page['children'] ?? [] as $child) {
$pages[$section['slug'] . '/' . $child['slug']] = $child['title'];
}
}
}
return $pages;
}
}

View File

@ -0,0 +1,245 @@
@php
$pageTotal = collect($sections)->sum(static fn (array $section): int => count($section['pages']));
@endphp
<x-layouts.admin>
<x-slot name="title">首页</x-slot>
<x-slot name="content">
<style>
#main-body > .container {
max-width: none;
width: auto;
margin-left: 1.5rem;
margin-right: 1.5rem;
}
.home-nav {
display: grid;
gap: 1.25rem;
margin: 1.5rem 0 2rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--vip-content-ink, #171717);
}
.home-nav__hello {
display: grid;
gap: 0.25rem;
}
.home-nav__hello-title {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
}
.home-nav__hello-hint {
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.home-nav__grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.home-nav__group {
display: grid;
gap: 0.5rem;
align-content: start;
padding: 1rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #fff);
}
.home-nav__group-title {
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--vip-border, #D8D2C7);
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-weight: 600;
line-height: 1.333;
}
.home-nav__links {
display: grid;
gap: 0.125rem;
margin: 0;
padding: 0;
list-style: none;
}
.home-nav__link {
display: flex;
align-items: center;
gap: 0.625rem;
min-height: 2rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
color: var(--vip-content-ink, #171717);
text-decoration: none;
}
.home-nav__link .material-icons-outlined {
width: 1.125rem;
flex: 0 0 1.125rem;
color: var(--vip-content-accent-dark, #856632);
font-size: 1.125rem;
text-align: center;
}
.home-nav__link:hover {
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
}
.home-nav__link:focus-visible {
outline: 2px solid var(--vip-content-accent-dark, #856632);
outline-offset: 2px;
}
.home-nav__all {
display: grid;
gap: 0.875rem;
padding: 1rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #fff);
}
.home-nav__all-title {
display: flex;
align-items: baseline;
gap: 0.5rem;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-weight: 600;
}
.home-nav__all-count {
color: var(--vip-content-muted, #595959);
font-size: 0.6875rem;
font-weight: 400;
}
.home-nav__section {
padding-top: 0.75rem;
border-top: 1px solid var(--vip-border, #D8D2C7);
}
.home-nav__section:first-of-type {
padding-top: 0;
border-top: 0;
}
.home-nav__section-title {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--vip-content-ink, #171717);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
list-style: none;
}
.home-nav__section-title::-webkit-details-marker {
display: none;
}
.home-nav__section-title .material-icons-outlined {
color: var(--vip-content-accent-dark, #856632);
font-size: 1rem;
}
.home-nav__section-title .home-nav__section-toggle {
margin-left: auto;
color: var(--vip-content-muted, #595959);
font-size: 0.6875rem;
font-weight: 400;
}
.home-nav__section[open] .home-nav__section-title {
color: var(--vip-content-accent-dark, #856632);
}
.home-nav__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.home-nav__chip {
padding: 0.25rem 0.625rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 999px;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
text-decoration: none;
}
.home-nav__chip:hover {
border-color: var(--vip-content-accent, #B9954F);
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
}
.home-nav__chip:focus-visible {
outline: 2px solid var(--vip-content-accent-dark, #856632);
outline-offset: 2px;
}
</style>
<div class="home-nav" data-home-nav>
<div class="home-nav__hello">
<p class="home-nav__hello-title">{{ user()->name }},欢迎回来</p>
<p class="home-nav__hello-hint">从下面的模块进入要处理的页面,也可以直接用左侧菜单搜索。</p>
</div>
<div class="home-nav__grid">
@foreach ($groups as $group)
<section class="home-nav__group" aria-label="{{ $group['title'] }}">
<h2 class="home-nav__group-title">{{ $group['title'] }}</h2>
<ul class="home-nav__links">
@foreach ($group['items'] as $item)
<li>
<a class="home-nav__link" href="{{ route('business.pages.show', ['section' => $item['section'], 'page' => $item['page']]) }}">
<span class="material-icons-outlined" aria-hidden="true">{{ $item['icon'] }}</span>
<span>{{ $item['title'] }}</span>
</a>
</li>
@endforeach
</ul>
</section>
@endforeach
</div>
@if ($sections !== [])
<section class="home-nav__all" aria-label="全部业务模块">
<h2 class="home-nav__all-title">
全部业务模块
<span class="home-nav__all-count">{{ count($sections) }} 个模块 · {{ $pageTotal }} 个页面</span>
</h2>
@foreach ($sections as $section)
<details class="home-nav__section">
<summary class="home-nav__section-title">
<span class="material-icons-outlined" aria-hidden="true">{{ $section['icon'] }}</span>
<span>{{ $section['title'] }}</span>
<span class="home-nav__section-toggle">{{ count($section['pages']) }} 个页面</span>
</summary>
<div class="home-nav__chips">
@foreach ($section['pages'] as $sectionPage)
<a class="home-nav__chip" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $sectionPage['slug']]) }}">{{ $sectionPage['title'] }}</a>
@endforeach
</div>
</details>
@endforeach
</section>
@endif
</div>
</x-slot>
</x-layouts.admin>

View File

@ -126,7 +126,7 @@
@stack('main_menu_company_start')
<div class="relative mb-5">
<div class="flex items-center">
<a href="{{ route('dashboard', ['company_id' => company_id()]) }}" class="flex items-center" title="返回首页">
<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>
@ -137,65 +137,13 @@
</span>
<span class="text-xs text-gray-400">财务运营管理</span>
</div>
</div>
</a>
</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'] !== []));
$prototypeGroups = \App\Utilities\SidebarMenu::groups(user());
@endphp
<style>
@ -234,14 +182,14 @@
@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])
@foreach ($group['items'] as $navItem)
<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
href="{{ route('business.pages.show', ['section' => $navItem['section'], 'page' => $navItem['page']]) }}"
@class(['prototype-tag-link', 'is-active' => request()->route('section') === $navItem['section'] && request()->route('page') === $navItem['page']])
@if (request()->route('section') === $navItem['section'] && request()->route('page') === $navItem['page']) aria-current="page" @endif
>
<span class="material-icons-outlined" aria-hidden="true">{{ $navIcon }}</span>
<span>{{ $navTitle }}</span>
<span class="material-icons-outlined" aria-hidden="true">{{ $navItem['icon'] }}</span>
<span>{{ $navItem['title'] }}</span>
</a>
@endforeach
</section>

View File

@ -129,7 +129,9 @@ class DingTalkLoginTest extends FeatureTestCase
'outcome' => 'success',
'user_id' => $created->id,
]);
$this->get(route('dashboard', ['company_id' => $this->company->id]))->assertRedirectContains('/workspace/');
$this->get(route('dashboard', ['company_id' => $this->company->id]))
->assertOk()
->assertSee('data-home-nav', false);
}
public function test_limited_login_gets_only_three_courier_pages_and_blocks_direct_urls(): void
@ -565,7 +567,10 @@ class DingTalkLoginTest extends FeatureTestCase
])->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'user_'.$created->id]));
$this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($created));
$this->actingAs($created)->withSession(['dingtalk_identity_id' => $created->dingtalkIdentity->id, 'dingtalk_roster_checked_at' => now()->timestamp])
->get(route('dashboard'))->assertRedirectContains('/workspace/store-management/basic-information');
->get(route('dashboard'))
->assertOk()
->assertSee('data-home-nav', false)
->assertSeeText('店铺基础信息');
$this->withExceptionHandling()->get(route('business.pages.show', [
'company_id' => $this->company->id, 'section' => 'company-management', 'page' => 'basic-information',
]))->assertForbidden();

View File

@ -19,6 +19,17 @@ use Tests\Feature\FeatureTestCase;
class PagesTest extends FeatureTestCase
{
public function testDashboardShowsHomeNavigationInsteadOfOpeningTheFirstBusinessPage(): void
{
$this->loginAs()
->get(route('dashboard', ['company_id' => company_id()]))
->assertOk()
->assertSee('data-home-nav', false)
->assertSeeText('全部业务模块')
->assertSee(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']), false)
->assertSee(route('business.pages.show', ['section' => 'company-management', 'page' => 'subject-operating-report']), false);
}
public function testInventoryReportProvidesAFilteredExportButton(): void
{
InventoryWarehouseNameMapping::query()->create([