feat(快递应付): 四个报价相关模块归入快递报价子目录 #307

Merged
limeng merged 1 commits from feature/limeng-courier-quote-directory into main 2026-10-12 12:17:14 +08:00
7 changed files with 135 additions and 19 deletions

View File

@ -49,7 +49,7 @@ class Navigation extends Controller
$titles['sections'][$sectionSlug] = $sectionTitle;
}
foreach ($section['pages'] ?? [] as $page) {
foreach (collect($section['pages'] ?? [])->flatMap(fn (array $page) => [$page, ...($page['children'] ?? [])]) as $page) {
$pageTitle = trim((string) ($pageTitles[$sectionSlug][$page['slug']] ?? ''));
if ($pageTitle !== '') {

View File

@ -23,6 +23,13 @@ class BusinessNavigation
$pageTitles[$sectionSlug][$page['slug']] ?? null,
$page['title']
);
foreach ($page['children'] ?? [] as $childIndex => $child) {
$section['pages'][$pageIndex]['children'][$childIndex]['title'] = self::title(
$pageTitles[$sectionSlug][$child['slug']] ?? null,
$child['title']
);
}
}
}
unset($section);

View File

@ -701,9 +701,23 @@ $navigation = [
'title' => '快递应付',
'icon' => 'local_shipping',
'pages' => [
['slug' => 'quote-management', 'title' => '报价管理'],
['slug' => 'courier-information', 'title' => '快递资料'],
['slug' => 'agreement-signing', 'title' => '协议签署'],
[
'slug' => 'courier-quote',
'title' => '快递报价',
'children' => [
['slug' => 'quote-management', 'title' => '报价管理'],
['slug' => 'courier-information', 'title' => '快递资料'],
['slug' => 'agreement-signing', 'title' => '协议签署'],
[
'slug' => 'courier-surcharge',
'title' => '附加费报价维护',
'tabs' => [
['slug' => 'quotes', 'title' => '附加费报价'],
['slug' => 'settlement', 'title' => '附加费结算'],
],
],
],
],
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
['slug' => 'shipping-data', 'title' => '发货数据'],
['slug' => 'return-pickup-management', 'title' => '退货取件管理'],
@ -712,14 +726,6 @@ $navigation = [
['slug' => 'compensation-details', 'title' => '赔偿明细'],
['slug' => 'advance-details', 'title' => '借支明细'],
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
[
'slug' => 'courier-surcharge',
'title' => '附加费报价维护',
'tabs' => [
['slug' => 'quotes', 'title' => '附加费报价'],
['slug' => 'settlement', 'title' => '附加费结算'],
],
],
['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'],
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
['slug' => 'freight-difference', 'title' => '运费差'],

View File

@ -5,10 +5,26 @@
<div data-courier-final>
<nav class="business-tab-bar courier-final-tabs" aria-label="快递应付子功能">
@foreach ($courierPages as $item)
<a class="business-tab-link" data-courier-tab="{{ $item['slug'] }}"
href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => $item['slug']]) }}">{{ $item['title'] }}</a>
@if (!empty($item['children']))
<button type="button" class="business-tab-link" data-courier-group="{{ $item['slug'] }}"
aria-expanded="false" aria-controls="courier-group-{{ $item['slug'] }}">{{ $item['title'] }}</button>
@else
<a class="business-tab-link" data-courier-tab="{{ $item['slug'] }}"
href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => $item['slug']]) }}">{{ $item['title'] }}</a>
@endif
@endforeach
</nav>
@foreach ($courierPages as $item)
@if (!empty($item['children']))
<nav class="business-tab-bar courier-final-tabs courier-final-subtabs" id="courier-group-{{ $item['slug'] }}"
aria-label="{{ $item['title'] }}子功能" data-courier-subtabs="{{ $item['slug'] }}" hidden>
@foreach ($item['children'] as $child)
<a class="business-tab-link" data-courier-tab="{{ $child['slug'] }}" data-courier-parent="{{ $item['slug'] }}"
href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => $child['slug']]) }}">{{ $child['title'] }}</a>
@endforeach
</nav>
@endif
@endforeach
<div role="status" aria-live="polite" data-courier-status></div>
<button type="button" class="business-tab-link" data-courier-retry hidden>重新加载</button>
@if (empty($courierPages))
@ -19,7 +35,12 @@
<style>
.courier-final-tabs { flex-wrap:wrap; gap:4px 16px; min-height:34px; padding:0 16px; margin-bottom:12px; }
.courier-final-tabs .business-tab-link { min-height:34px; font-size:12px; color:var(--vip-content-muted, #595959); }
.courier-final-tabs button.business-tab-link { border:0; background:transparent; padding:0; font-family:inherit; cursor:pointer; }
.courier-final-tabs .business-tab-link:focus-visible { outline:2px solid var(--vip-content-accent-dark, #856632); outline-offset:3px; }
.courier-final-subtabs[hidden] { display:none; }
.courier-final-subtabs { border:0; border-bottom:1px solid var(--vip-border, #D8D2C7); border-radius:0; box-shadow:none; }
.courier-final-tabs .is-active { color:var(--vip-content-accent-dark, #856632); }
@media (max-width:640px) { .courier-final-tabs { flex-wrap:nowrap; } }
[data-courier-frames] iframe { width:100%; height:calc(100vh - 260px); min-height:400px; border:0; }
[data-courier-frames] iframe[hidden] { display:none; }
[data-courier-retry][hidden] { display:none!important; }
@ -30,6 +51,9 @@
const root = document.querySelector('[data-courier-final]');
if (!root) return;
const links = [...root.querySelectorAll('[data-courier-tab]')];
const groups = [...root.querySelectorAll('[data-courier-group]')];
const submenus = [...root.querySelectorAll('[data-courier-subtabs]')];
const groupSelections = new Map();
const frames = new Map();
const status = root.querySelector('[data-courier-status]');
const retry = root.querySelector('[data-courier-retry]');
@ -38,6 +62,14 @@
if (!link) return;
selected = link;
retry.hidden = true;
const parent = link.dataset.courierParent;
if (parent) groupSelections.set(parent, link);
groups.forEach(group => {
const active = group.dataset.courierGroup === parent;
group.classList.toggle('is-active', active);
group.setAttribute('aria-expanded', String(active));
});
submenus.forEach(menu => menu.hidden = menu.dataset.courierSubtabs !== parent);
links.forEach(item => {
item.classList.toggle('is-active', item === link);
if (item === link) item.setAttribute('aria-current', 'page');
@ -93,8 +125,16 @@
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault(); select(link);
}));
const groupTarget = group => group && (groupSelections.get(group.dataset.courierGroup)
|| links.find(link => link.dataset.courierParent === group.dataset.courierGroup));
groups.forEach(group => group.addEventListener('click', () => select(groupTarget(group))));
retry.addEventListener('click', () => select(selected, false, true));
const restore = () => select(links.find(link => link.dataset.courierTab === new URL(location.href).searchParams.get('view')) || links[0], false);
const restore = () => {
const view = new URL(location.href).searchParams.get('view');
select(links.find(link => link.dataset.courierTab === view)
|| groupTarget(groups.find(group => group.dataset.courierGroup === view))
|| groupTarget(groups[0]) || links[0], false);
};
window.addEventListener('popstate', restore);
restore();
})();

View File

@ -27,10 +27,10 @@
@if (! empty($section['pages']))
<div class="sm:col-span-3 grid grid-cols-1 gap-4">
@foreach ($section['pages'] as $page)
@foreach (collect($section['pages'])->flatMap(fn ($page) => [$page, ...($page['children'] ?? [])]) as $page)
@php $pageInput = "page_titles[{$section['slug']}][{$page['slug']}]"; @endphp
<div>
<x-form.label for="{{ $pageInput }}">二级功能</x-form.label>
<x-form.label for="{{ $pageInput }}">功能名称</x-form.label>
<input
type="text"
id="{{ $pageInput }}"

View File

@ -5,6 +5,7 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Support\QuoteManagementSnapshot;
use App\Support\ReverseLogisticsQuoteSnapshot;
use App\Utilities\BusinessNavigation;
use Illuminate\Http\Exceptions\HttpResponseException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\FeatureTestCase;
@ -164,7 +165,13 @@ class QuoteManagementPageTest extends FeatureTestCase
->firstWhere('slug', 'courier-payables');
$this->assertIsArray($courierPayables);
$this->assertContains('quote-management', array_column($courierPayables['pages'], 'slug'));
$quoteDirectory = collect($courierPayables['pages'])->firstWhere('slug', 'courier-quote');
$this->assertSame([
'quote-management',
'courier-information',
'agreement-signing',
'courier-surcharge',
], array_column($quoteDirectory['children'], 'slug'));
$this->assertNotContains('reverse-logistics-quotes', array_column($courierPayables['pages'], 'slug'));
$this->user->attachPermission(
@ -194,6 +201,52 @@ class QuoteManagementPageTest extends FeatureTestCase
->assertSee('data-reverse-rate-body', false);
}
public function testCourierQuoteDirectoryKeepsPageLabelsAndExactPermissions(): void
{
setting()->set('navigation.titles', [
'pages' => ['courier-payables' => ['quote-management' => '快递报价单']],
]);
setting()->save();
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([
\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id,
]);
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [$subject => [
'courier-payables/quote-management',
'courier-payables/courier-surcharge',
]],
]);
setting()->save();
$navigation = BusinessNavigation::all();
$section = collect($navigation)->firstWhere('slug', 'courier-payables');
$directory = collect($section['pages'])->firstWhere('slug', 'courier-quote');
$this->assertSame('快递报价单', collect($directory['children'])->firstWhere('slug', 'quote-management')['title']);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()
->get(route('business.pages.show', ['section' => 'courier-payables']))
->assertOk()
->assertSee('data-courier-group="courier-quote"', false)
->assertSeeText('快递报价单')
->assertSee('data-courier-tab="courier-surcharge"', false)
->assertDontSee('data-courier-tab="courier-information"', false)
->assertDontSee('data-courier-tab="agreement-signing"', false);
try {
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information']));
$this->fail('未授权的快递资料页面不应访问。');
} catch (HttpException|HttpResponseException $exception) {
$status = method_exists($exception, 'getStatusCode')
? $exception->getStatusCode()
: $exception->getResponse()->getStatusCode();
$this->assertSame(404, $status);
}
}
public function testQuoteManagementPageKeepsTheShellWhenThePrivateSnapshotIsMissing(): void
{
app()->instance(

View File

@ -245,7 +245,9 @@ class NavigationTest extends FeatureTestCase
$this->loginAs()
->get(route('settings.navigation.manage'))
->assertOk()
->assertSeeText('侧边栏功能名称');
->assertSeeText('侧边栏功能名称')
->assertSee('name="page_titles[courier-payables][quote-management]"', false)
->assertSee('name="page_titles[courier-payables][courier-surcharge]"', false);
$this->patch(route('settings.navigation.save'), [
'section_titles' => [
@ -255,6 +257,10 @@ class NavigationTest extends FeatureTestCase
'company-management' => [
'subject-operating-report' => '主体经营',
],
'courier-payables' => [
'quote-management' => '快递报价单',
'unknown-page' => '不应保存',
],
],
])->assertOk();
@ -263,6 +269,10 @@ class NavigationTest extends FeatureTestCase
$this->assertSame('企业档案', $navigation[0]['title']);
$this->assertSame('主体经营', $companyRegistration['title']);
$courier = collect($navigation)->firstWhere('slug', 'courier-payables');
$quotes = collect($courier['pages'])->firstWhere('slug', 'courier-quote');
$this->assertSame('快递报价单', collect($quotes['children'])->firstWhere('slug', 'quote-management')['title']);
$this->assertArrayNotHasKey('unknown-page', setting('navigation.titles')['pages']['courier-payables']);
}
public function testOrderDataAndRefundAreConfiguredBetweenInvoiceAndReceivablesMenus()