From 4089cdca93c95cd94ed4fe71fc57fb2d02b03571 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Mon, 26 Oct 2026 14:39:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=B3=BB=E7=BB=9F=E5=8C=96?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=E5=86=85=E5=AE=B9=E5=90=88=E5=B9=B6=E4=B8=8E?= =?UTF-8?q?=E4=B8=89=E7=BA=A7=E5=BA=8F=E5=8F=B7=20(#594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/SystemizationTodos.php | 175 +++++++++++++++--- .../pages/systemization-todos.blade.php | 32 +++- .../Business/SystemizationTodosTest.php | 95 +++++++++- 3 files changed, 261 insertions(+), 41 deletions(-) diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 80d8b7fc..650b4c21 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -79,33 +79,45 @@ class SystemizationTodos extends Controller $items = $query ->select($query->qualifyColumn('*')) - ->selectRaw( - "ROW_NUMBER() OVER ( - PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__') - ORDER BY - CASE WHEN status = '已完成' THEN 1 ELSE 0 END, - CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END, - section, - CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, - due_date, - sort_order, - id - ) AS category_sequence" - ) - ->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END") - ->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END") - ->orderBy('category') - ->orderByRaw("CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END") - ->orderBy('section') - ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END') - ->orderBy('due_date') ->orderBy('sort_order') ->orderBy('id') ->get(); - if ($filters['sequence'] > 0) { + $sequenceMap = $this->hierarchySequenceMap(); + $items->each(function (SystemizationTodo $todo) use ($sequenceMap): void { + $sequence = $sequenceMap[$todo->id] ?? [ + 'type_sequence' => 0, + 'content_sequence' => 0, + 'item_sequence' => 0, + 'order' => PHP_INT_MAX, + 'category_display' => '', + 'section_display' => '', + ]; + $todo->setAttribute('type_sequence', $sequence['type_sequence']); + $todo->setAttribute('content_sequence', $sequence['content_sequence']); + $todo->setAttribute('item_sequence', $sequence['item_sequence']); + $todo->setAttribute('hierarchy_order', $sequence['order']); + $todo->setAttribute('category_display', $sequence['category_display']); + $todo->setAttribute('section_display', $sequence['section_display']); + }); + $items = $items->sortBy('hierarchy_order')->values(); + if ($filters['type_sequence'] > 0) { $items = $items ->filter(static fn (SystemizationTodo $todo): bool => - (int) $todo->category_sequence === $filters['sequence'] + (int) $todo->type_sequence === $filters['type_sequence'] + ) + ->values(); + } + if ($filters['content_sequence'] > 0) { + $items = $items + ->filter(static fn (SystemizationTodo $todo): bool => + (int) $todo->content_sequence === $filters['content_sequence'] + ) + ->values(); + } + if ($filters['item_sequence'] > 0) { + $items = $items + ->filter(static fn (SystemizationTodo $todo): bool => + (int) $todo->item_sequence === $filters['item_sequence'] ) ->values(); } @@ -172,6 +184,121 @@ class SystemizationTodos extends Controller ]; } + private function hierarchySequenceMap(): array + { + $todos = SystemizationTodo::query() + ->orderBy('sort_order') + ->orderBy('id') + ->get(['id', 'category', 'section', 'sort_order']); + $rows = $todos->map(function (SystemizationTodo $todo): array { + $category = $this->hierarchyLabel($todo->category); + $section = $this->hierarchyLabel($todo->section); + + return [ + 'category_key' => $category === '' ? "\0" : $category, + 'category_label' => $category, + 'section_key' => $section === '' ? "\0" : $section, + 'section_label' => $section, + 'sort_order' => (int) $todo->sort_order, + 'id' => (int) $todo->id, + ]; + })->all(); + + $categoryOrder = []; + $categoryLabels = []; + $sectionOrder = []; + $sectionLabels = []; + $groupedRows = []; + foreach ($rows as $row) { + $categoryKey = $row['category_key']; + $sectionKey = $row['section_key']; + $categoryOrder[$categoryKey] = min( + $categoryOrder[$categoryKey] ?? PHP_INT_MAX, + $row['sort_order'], + ); + $categoryLabels[$categoryKey] = $row['category_label']; + $sectionOrder[$categoryKey][$sectionKey] = min( + $sectionOrder[$categoryKey][$sectionKey] ?? PHP_INT_MAX, + $row['sort_order'], + ); + $sectionLabels[$categoryKey][$sectionKey] = $row['section_label']; + $groupedRows[$categoryKey][$sectionKey][] = $row; + } + + $categoryKeys = array_keys($categoryOrder); + usort($categoryKeys, function (string $left, string $right) use ( + $categoryOrder, + $categoryLabels, + ): int { + return $this->compareHierarchyGroups( + $left, + $right, + $categoryOrder, + $categoryLabels, + ); + }); + + $sequenceMap = []; + $hierarchyOrder = 0; + foreach ($categoryKeys as $categoryIndex => $categoryKey) { + $contentKeys = array_keys($sectionOrder[$categoryKey]); + usort($contentKeys, function (string $left, string $right) use ( + $categoryKey, + $sectionOrder, + $sectionLabels, + ): int { + return $this->compareHierarchyGroups( + $left, + $right, + $sectionOrder[$categoryKey], + $sectionLabels[$categoryKey], + ); + }); + + foreach ($contentKeys as $contentIndex => $contentKey) { + $contentRows = $groupedRows[$categoryKey][$contentKey]; + usort($contentRows, static fn (array $left, array $right): int => + [$left['sort_order'], $left['id']] <=> [$right['sort_order'], $right['id']] + ); + + foreach ($contentRows as $itemIndex => $row) { + $sequenceMap[$row['id']] = [ + 'type_sequence' => $categoryIndex + 1, + 'content_sequence' => $contentIndex + 1, + 'item_sequence' => $itemIndex + 1, + 'order' => $hierarchyOrder++, + 'category_display' => $row['category_label'], + 'section_display' => $row['section_label'], + ]; + } + } + } + + return $sequenceMap; + } + + private function compareHierarchyGroups( + string $left, + string $right, + array $sortOrders, + array $labels, + ): int { + $comparison = ($sortOrders[$left] ?? PHP_INT_MAX) <=> ($sortOrders[$right] ?? PHP_INT_MAX); + + return $comparison !== 0 + ? $comparison + : strnatcasecmp($labels[$left] ?? '', $labels[$right] ?? ''); + } + + private function hierarchyLabel(?string $value): string + { + return trim((string) preg_replace( + '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u', + '', + (string) $value, + )); + } + public function store(Request $request): RedirectResponse { $data = $this->validated($request); @@ -280,7 +407,9 @@ class SystemizationTodos extends Controller 'assignees' => $this->peopleFilter('assignee'), 'start_date' => $this->normalizeDateFilter(request('start_date')), 'due_date' => $this->normalizeDateFilter(request('due_date')), - 'sequence' => max(0, (int) request('sequence', 0)), + 'type_sequence' => max(0, (int) request('type_sequence', 0)), + 'content_sequence' => max(0, (int) request('content_sequence', 0)), + 'item_sequence' => max(0, (int) request('item_sequence', 0)), 'keyword' => trim((string) request('keyword', '')), ]; } diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index 04a9438d..c9409498 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -49,7 +49,11 @@ .sys-todo-table__index-cell { width:3.6rem; min-width:3.6rem; text-align:center; } .sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; } .sys-todo-table__group { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.7; vertical-align:middle; } + .sys-todo-table__group-content { display:inline-flex; align-items:flex-start; gap:.38rem; min-width:0; } + .sys-todo-table__group-text { min-width:0; } + .sys-todo-table__level { display:inline-flex; flex:0 0 auto; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; margin-top:.05rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); font-size:.68rem; font-weight:700; font-variant-numeric:tabular-nums; } .sys-todo-table__type { position:sticky; left:0; z-index:3; width:5.4rem; min-width:5.4rem; text-align:center; white-space:nowrap; box-shadow:1px 0 0 #e5ddcf; } + .sys-todo-table__type .sys-todo-table__group-text { white-space:normal; } .sys-todo-table__section { min-width:11rem; max-width:17rem; } .sys-todo-table__title, .sys-todo-table__progress, .sys-todo-table__notes { line-height:1.45; white-space:pre-line; overflow-wrap:anywhere; } .sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; } @@ -199,7 +203,9 @@ @endforeach - + + + @php $selectedOwners = $todoOwners->filter( static fn (string $owner): bool => in_array($owner, $todoFilters['owners'], true) @@ -266,16 +272,16 @@ )); }; $sectionLabels = $items - ->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->section)) + ->map(static fn ($groupItem): string => trim((string) $groupItem->section_display)) ->all(); $titleLabels = $items ->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->title)) ->all(); $groupValues = [ - 'category' => $items->map(static fn ($groupItem): string => trim((string) ($groupItem->category ?? '')))->all(), - 'section' => $items->map(static fn ($groupItem, int $index): string => trim((string) ($groupItem->category ?? '')) + 'category' => $items->map(static fn ($groupItem): string => (string) ((int) $groupItem->type_sequence))->all(), + 'section' => $items->map(static fn ($groupItem): string => (int) $groupItem->type_sequence . "\0" - . ($sectionLabels[$index] ?? ''))->all(), + . (int) $groupItem->content_sequence)->all(), ]; $groupRowspans = ['category' => [], 'section' => []]; foreach (['category', 'section'] as $groupField) { @@ -327,7 +333,7 @@ - + @@ -371,14 +377,22 @@ @endphp @if ($categorySpan !== 0) - + @endif @if ($sectionSpan !== 0) @endif - + diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index 4336d67c..1fb00a51 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -28,7 +28,9 @@ class SystemizationTodosTest extends FeatureTestCase ->assertSeeText('从 Excel 同步待办') ->assertSeeText('总负责人') ->assertSeeText('责任人') - ->assertSeeText('类型内序号') + ->assertSeeText('类型序号') + ->assertSeeText('内容序号') + ->assertSeeText('待办序号') ->assertSeeText('已逾期') ->assertSeeText('开始时间') ->assertSeeText('完成时间') @@ -189,10 +191,11 @@ class SystemizationTodosTest extends FeatureTestCase $pageResponse ->assertOk() ->assertSee('rowspan="2"', false) - ->assertSee('', false) + ->assertSee('', false) ->assertSee('sys-todo-table__type', false) - ->assertSee('title="当前类型内序号">1', false) - ->assertSee('title="当前类型内序号">2', false) + ->assertSee('title="类型序号">1', false) + ->assertSee('title="内容序号">1', false) + ->assertSee('title="当前内容内待办序号">2', false) ->assertDontSee('1、平台收入核对') ->assertDontSee('3、退款核销') ->assertDontSee('1、收入事项 A') @@ -206,17 +209,25 @@ class SystemizationTodosTest extends FeatureTestCase ); $pageItems = $pageContent['items']; $this->assertSame( - [1, 2], + [1, 1], $pageItems->where('category', '收入') - ->pluck('category_sequence') + ->pluck('type_sequence') ->map(static fn (mixed $value): int => (int) $value) ->values() ->all(), ); $this->assertSame( - [1], + [1, 2], + $pageItems->where('category', '收入') + ->pluck('item_sequence') + ->map(static fn (mixed $value): int => (int) $value) + ->values() + ->all(), + ); + $this->assertSame( + [2], $pageItems->where('category', '成本') - ->pluck('category_sequence') + ->pluck('type_sequence') ->map(static fn (mixed $value): int => (int) $value) ->values() ->all(), @@ -226,11 +237,18 @@ class SystemizationTodosTest extends FeatureTestCase 'section' => 'other', 'page' => 'systemization-todos', 'category' => '收入', - 'sequence' => 2, + 'item_sequence' => 2, ]))->assertOk(); $sequenceContent = $sequenceResponse->viewData('content'); $this->assertSame(['2、收入事项 B'], $sequenceContent['items']->pluck('title')->all()); + $typeSequenceContent = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + 'type_sequence' => 2, + ]))->assertOk()->viewData('content'); + $this->assertSame(['3、成本事项'], $typeSequenceContent['items']->pluck('title')->all()); + $highlightedTodo = $pageItems->firstWhere('title', '2、收入事项 B'); $this->get(route('business.pages.show', [ 'section' => 'other', @@ -285,6 +303,65 @@ class SystemizationTodosTest extends FeatureTestCase ); } + public function test_same_content_stays_merged_and_receives_scoped_hierarchy_sequences(): void + { + $this->loginAs(); + + $pending = $this->createTodo('收入', '钉钉流程', '待办事项 A', '2026-10-30', 1); + $completed = $this->createTodo('收入', '钉钉流程', '待办事项 B', '2026-10-10', 2); + $completed->update([ + 'status' => SystemizationTodo::STATUS_COMPLETED, + 'completed_at' => '2026-10-10', + ]); + $inProgress = $this->createTodo('收入', '钉钉流程', '待办事项 C', '2026-10-20', 3); + $inProgress->update(['status' => SystemizationTodo::STATUS_IN_PROGRESS]); + $secondContent = $this->createTodo('收入', '报销流程', '待办事项 D', '2026-11-01', 4); + $cost = $this->createTodo('成本', '成本核对', '待办事项 E', '2026-11-02', 5); + + $response = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ]))->assertOk(); + $content = $response->viewData('content'); + $items = $content['items']; + + $this->assertSame( + [$pending->id, $completed->id, $inProgress->id, $secondContent->id, $cost->id], + $items->pluck('id')->all(), + ); + $this->assertSame( + [[1, 1, [1, 2, 3]], [1, 2, [1]], [2, 1, [1]]], + $items + ->groupBy(static fn (SystemizationTodo $todo): string => $todo->type_sequence . ':' . $todo->content_sequence) + ->map(static fn ($group): array => [ + (int) $group->first()->type_sequence, + (int) $group->first()->content_sequence, + $group->pluck('item_sequence')->map(static fn (mixed $value): int => (int) $value)->all(), + ]) + ->values() + ->all(), + ); + $contentResponse = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + 'type_sequence' => 1, + 'content_sequence' => 2, + ]))->assertOk(); + $this->assertSame( + [$secondContent->id], + $contentResponse->viewData('content')['items']->pluck('id')->all(), + ); + $this->assertSame( + [$completed->id, $inProgress->id], + $items + ->where('section', '钉钉流程') + ->whereIn('status', [SystemizationTodo::STATUS_COMPLETED, SystemizationTodo::STATUS_IN_PROGRESS]) + ->pluck('id') + ->all(), + ); + $this->assertSame(1, substr_count((string) $response->getContent(), '>钉钉流程<')); + } + public function test_page_renders_all_todos_on_one_continuous_list(): void { $this->loginAs();
类型内容序号待办事项总负责人责任人类型内容待办序号待办事项总负责人责任人 开始时间完成时间状态完成情况完成截图备注
1) rowspan="{{ $categorySpan }}" @endif>{{ $item->category ?: '—' }} 1) rowspan="{{ $categorySpan }}" @endif> + + {{ (int) $item->type_sequence }} + {{ $item->category_display ?: '未分类' }} + + 1) rowspan="{{ $sectionSpan }}" @endif> -
{{ $sectionLabels[$loop->index] ?: '—' }}
+ + {{ (int) $item->content_sequence }} + {{ $sectionLabels[$loop->index] ?: '未填写内容' }} +
{{ (int) ($item->category_sequence ?? 0) }}{{ (int) $item->item_sequence }}
{{ $titleLabels[$loop->index] }}
{{ $item->owner ?: '—' }} {{ $item->assignee ?: '—' }}内容序号待办事项内容待办序号待办事项