统一系统化待办筛选与新增选项 (#569)

This commit is contained in:
yueyang 2026-10-25 23:20:42 +08:00
parent 3c7757d848
commit 4c30f2e628
3 changed files with 254 additions and 71 deletions

View File

@ -36,8 +36,16 @@ class SystemizationTodos extends Controller
if ($filters['category'] !== '') {
$query->where('category', $filters['category']);
}
if ($filters['owner'] !== '') {
$query->where('owner', 'like', '%' . addcslashes($filters['owner'], '%_\\') . '%');
if ($filters['owners'] !== []) {
$query->where(function ($builder) use ($filters): void {
foreach ($filters['owners'] as $owner) {
$builder->orWhere(
'owner',
'like',
'%' . addcslashes($owner, '%_\\') . '%',
);
}
});
}
if ($filters['assignees'] !== []) {
$query->where(function ($builder) use ($filters): void {
@ -103,6 +111,30 @@ class SystemizationTodos extends Controller
->distinct()
->orderBy('category')
->pluck('category');
$statusCounts = (clone $base)
->selectRaw('status, COUNT(*) AS aggregate')
->groupBy('status')
->pluck('aggregate', 'status');
$overdueCount = (clone $base)
->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
->whereNotNull('due_date')
->whereDate('due_date', '<', today())
->count();
$statusOptions = collect();
if ($overdueCount > 0) {
$statusOptions->push([
'value' => self::OVERDUE_FILTER,
'label' => '已逾期',
]);
}
foreach (SystemizationTodo::STATUSES as $status) {
if ((int) $statusCounts->get($status, 0) > 0) {
$statusOptions->push([
'value' => $status,
'label' => $status,
]);
}
}
return [
'view' => 'business.pages.systemization-todos',
@ -113,17 +145,13 @@ class SystemizationTodos extends Controller
'todoOwners' => $this->peopleOptions('owner'),
'todoAssignees' => $this->peopleOptions('assignee'),
'todoStatuses' => SystemizationTodo::STATUSES,
'todoOverdueFilter' => self::OVERDUE_FILTER,
'todoStatusOptions' => $statusOptions,
'todoSummary' => [
'total' => (clone $base)->count(),
'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(),
'in_progress' => (clone $base)->where('status', SystemizationTodo::STATUS_IN_PROGRESS)->count(),
'completed' => (clone $base)->where('status', SystemizationTodo::STATUS_COMPLETED)->count(),
'overdue' => (clone $base)
->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
->whereNotNull('due_date')
->whereDate('due_date', '<', today())
->count(),
'pending' => (int) $statusCounts->get(SystemizationTodo::STATUS_PENDING, 0),
'in_progress' => (int) $statusCounts->get(SystemizationTodo::STATUS_IN_PROGRESS, 0),
'completed' => (int) $statusCounts->get(SystemizationTodo::STATUS_COMPLETED, 0),
'overdue' => $overdueCount,
],
'todoRoutes' => [
'store' => route('business.systemization-todos.store'),
@ -234,14 +262,6 @@ class SystemizationTodos extends Controller
private function filters(): array
{
$status = (string) request('status', '');
$assignees = collect((array) request('assignee', []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
return [
'status' => $status === self::OVERDUE_FILTER
@ -249,14 +269,26 @@ class SystemizationTodos extends Controller
? $status
: '',
'category' => trim((string) request('category', '')),
'owner' => trim((string) request('owner', '')),
'assignees' => $assignees,
'owners' => $this->peopleFilter('owner'),
'assignees' => $this->peopleFilter('assignee'),
'start_date' => $this->normalizeDateFilter(request('start_date')),
'due_date' => $this->normalizeDateFilter(request('due_date')),
'keyword' => trim((string) request('keyword', '')),
];
}
private function peopleFilter(string $key): array
{
return collect((array) request($key, []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
}
private function peopleOptions(string $column): Collection
{
return SystemizationTodo::query()

View File

@ -21,12 +21,13 @@
.sys-todo-filter label { display:grid; flex:1 1 7.4rem; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; }
.sys-todo-filter label.sys-todo-filter__keyword { flex:2.2 1 16rem; }
.sys-todo-filter input, .sys-todo-filter select { width:100%; height:2.125rem; padding:0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
.sys-todo-filter__multi { position:relative; flex:1 1 8.2rem; min-width:0; color:#595959; font-size:.72rem; }
.sys-todo-filter__multi > summary { position:relative; display:grid; align-content:center; gap:.18rem; min-height:2.95rem; padding:.28rem 1.8rem .28rem .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; cursor:pointer; list-style:none; }
.sys-todo-filter__multi > summary::-webkit-details-marker { display:none; }
.sys-todo-filter__multi > summary .material-icons-outlined { position:absolute; top:50%; right:.55rem; color:#777; font-size:1.05rem; transform:translateY(-50%); transition:transform 150ms ease; }
.sys-todo-filter__multi[open] > summary { border-color:var(--sys-gold); box-shadow:0 0 0 3px rgba(133,102,50,.14); }
.sys-todo-filter__multi[open] > summary .material-icons-outlined { transform:translateY(-50%) rotate(180deg); }
.sys-todo-filter__multi { display:grid; flex:1 1 8.2rem; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; }
.sys-todo-filter__multi details { position:relative; min-width:0; }
.sys-todo-filter__multi > details > summary { position:relative; display:flex; align-items:center; min-height:2.125rem; padding:0 1.8rem 0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; cursor:pointer; list-style:none; }
.sys-todo-filter__multi > details > summary::-webkit-details-marker { display:none; }
.sys-todo-filter__multi > details > summary .material-icons-outlined { position:absolute; top:50%; right:.55rem; color:#777; font-size:1.05rem; transform:translateY(-50%); transition:transform 150ms ease; }
.sys-todo-filter__multi details[open] > summary { border-color:var(--sys-gold); box-shadow:0 0 0 3px rgba(133,102,50,.14); }
.sys-todo-filter__multi details[open] > summary .material-icons-outlined { transform:translateY(-50%) rotate(180deg); }
.sys-todo-filter__multi-label { color:#595959; font-size:.72rem; line-height:1; }
.sys-todo-filter__multi-value { overflow:hidden; font-size:.8125rem; line-height:1.2; text-overflow:ellipsis; white-space:nowrap; }
.sys-todo-filter__multi-menu { position:absolute; top:calc(100% + .3rem); left:0; z-index:20; display:grid; width:max(100%,13rem); max-height:17rem; padding:.35rem; border:1px solid var(--sys-border); border-radius:.55rem; background:#fff; box-shadow:0 8px 22px rgba(23,23,23,.16); overflow:auto; }
@ -97,6 +98,12 @@
.sys-todo-field input, .sys-todo-field select, .sys-todo-field textarea { width:100%; padding:.5rem .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
.sys-todo-field input, .sys-todo-field select { min-height:2.15rem; }
.sys-todo-field textarea { min-height:5.5rem; resize:vertical; line-height:1.55; }
.sys-todo-new-category { margin-top:.35rem; }
.sys-todo-new-category[hidden] { display:none; }
.sys-todo-owner-checks { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.2rem .45rem; max-height:7rem; padding:.4rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; overflow:auto; }
.sys-todo-owner-checks label { display:flex; align-items:center; gap:.35rem; min-height:1.6rem; color:#333; font-size:.76rem; cursor:pointer; }
.sys-todo-owner-checks label:hover { color:var(--sys-gold); }
.sys-todo-owner-checks input[type="checkbox"] { flex:0 0 auto; width:1rem; height:1rem; min-height:0; padding:0; accent-color:var(--sys-gold); }
.sys-todo-upload-zone { display:flex; flex-wrap:wrap; align-items:center; gap:.45rem .65rem; min-height:4.5rem; padding:.7rem; border:1px dashed #cfc3ad; border-radius:.55rem; background:#fcfaf5; }
.sys-todo-upload-zone input[type="file"] { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.sys-todo-upload-button { display:inline-flex; align-items:center; justify-content:center; min-height:2rem; padding:0 .75rem; border:1px solid var(--sys-gold); border-radius:.4rem; background:var(--sys-gold); color:#fff; font-size:.78rem; cursor:pointer; }
@ -169,9 +176,8 @@
<form class="sys-todo-filter" method="GET" action="{{ request()->url() }}">
<label>状态<select name="status">
<option value="">全部状态</option>
<option value="{{ $todoOverdueFilter }}" @selected($todoFilters['status'] === $todoOverdueFilter)>已逾期</option>
@foreach ($todoStatuses as $status)
<option value="{{ $status }}" @selected($todoFilters['status'] === $status)>{{ $status }}</option>
@foreach ($todoStatusOptions as $statusOption)
<option value="{{ $statusOption['value'] }}" @selected($todoFilters['status'] === $statusOption['value'])>{{ $statusOption['label'] }}</option>
@endforeach
</select></label>
<label>类型<select name="category">
@ -180,37 +186,54 @@
<option value="{{ $category }}" @selected($todoFilters['category'] === $category)>{{ $category }}</option>
@endforeach
</select></label>
<label>总负责人<select name="owner">
<option value="">全部总负责人</option>
@foreach ($todoOwners as $owner)
<option value="{{ $owner }}" @selected($todoFilters['owner'] === $owner)>{{ $owner }}</option>
@endforeach
</select></label>
@php
$selectedOwners = $todoOwners->filter(
static fn (string $owner): bool => in_array($owner, $todoFilters['owners'], true)
);
$selectedAssignees = $todoAssignees->filter(
static fn (string $assignee): bool => in_array($assignee, $todoFilters['assignees'], true)
);
$assigneeSummary = $selectedAssignees->isEmpty()
? '全部责任人'
: '已选 ' . $selectedAssignees->count() . ' 人:' . $selectedAssignees->implode('、');
$ownerSummary = $selectedOwners->isEmpty() ? '全部总负责人' : '已选 ' . $selectedOwners->count() . ' 人';
$assigneeSummary = $selectedAssignees->isEmpty() ? '全部责任人' : '已选 ' . $selectedAssignees->count() . ' 人';
@endphp
<details class="sys-todo-filter__multi" data-sys-todo-assignee-filter>
<summary>
<span class="sys-todo-filter__multi-label">责任人</span>
<span class="sys-todo-filter__multi-value" data-sys-todo-assignee-summary>{{ $assigneeSummary }}</span>
<span class="material-icons-outlined" aria-hidden="true">expand_more</span>
</summary>
<div class="sys-todo-filter__multi-menu">
@forelse ($todoAssignees as $assignee)
<label>
<input type="checkbox" name="assignee[]" value="{{ $assignee }}" @checked(in_array($assignee, $todoFilters['assignees'], true))>
<span>{{ $assignee }}</span>
</label>
@empty
<span class="sys-todo-filter__multi-empty">暂无可选责任人</span>
@endforelse
</div>
</details>
<div class="sys-todo-filter__multi">
<span class="sys-todo-filter__multi-label">总负责人</span>
<details data-sys-todo-owner-filter>
<summary>
<span class="sys-todo-filter__multi-value" data-sys-todo-owner-summary title="{{ $selectedOwners->implode('、') }}">{{ $ownerSummary }}</span>
<span class="material-icons-outlined" aria-hidden="true">expand_more</span>
</summary>
<div class="sys-todo-filter__multi-menu">
@forelse ($todoOwners as $owner)
<label>
<input type="checkbox" name="owner[]" value="{{ $owner }}" @checked(in_array($owner, $todoFilters['owners'], true))>
<span>{{ $owner }}</span>
</label>
@empty
<span class="sys-todo-filter__multi-empty">暂无可选总负责人</span>
@endforelse
</div>
</details>
</div>
<div class="sys-todo-filter__multi">
<span class="sys-todo-filter__multi-label">责任人</span>
<details data-sys-todo-assignee-filter>
<summary>
<span class="sys-todo-filter__multi-value" data-sys-todo-assignee-summary title="{{ $selectedAssignees->implode('、') }}">{{ $assigneeSummary }}</span>
<span class="material-icons-outlined" aria-hidden="true">expand_more</span>
</summary>
<div class="sys-todo-filter__multi-menu">
@forelse ($todoAssignees as $assignee)
<label>
<input type="checkbox" name="assignee[]" value="{{ $assignee }}" @checked(in_array($assignee, $todoFilters['assignees'], true))>
<span>{{ $assignee }}</span>
</label>
@empty
<span class="sys-todo-filter__multi-empty">暂无可选责任人</span>
@endforelse
</div>
</details>
</div>
<label>开始时间<input type="date" name="start_date" value="{{ $todoFilters['start_date'] }}"></label>
<label>完成时间<input type="date" name="due_date" value="{{ $todoFilters['due_date'] }}"></label>
<label class="sys-todo-filter__keyword">关键词<input type="search" name="keyword" value="{{ $todoFilters['keyword'] }}" placeholder="事项、内容、责任人、进展或备注"></label>
@ -418,11 +441,34 @@
</div>
<div class="sys-todo-dialog__body">
<div class="sys-todo-form-grid">
<label class="sys-todo-field sys-todo-field--3">类型<input name="category" maxlength="100" list="sysTodoCategories"></label>
<div class="sys-todo-field sys-todo-field--3">
<label for="sysTodoCategorySelect">类型</label>
<select id="sysTodoCategorySelect" data-sys-todo-category-select>
<option value="">请选择类型</option>
@foreach ($todoCategories as $category)
<option value="{{ $category }}">{{ $category }}</option>
@endforeach
<option value="__new__">+ 新增类型</option>
</select>
<input type="hidden" name="category" data-sys-todo-category-value>
<div class="sys-todo-new-category" data-sys-todo-new-category hidden>
<input type="text" maxlength="100" placeholder="输入新类型" data-sys-todo-new-category-input>
</div>
</div>
<label class="sys-todo-field sys-todo-field--4">内容<input name="section" maxlength="255"></label>
<label class="sys-todo-field sys-todo-field--3">状态<select name="status" required>@foreach ($todoStatuses as $status)<option value="{{ $status }}">{{ $status }}</option>@endforeach</select></label>
<label class="sys-todo-field sys-todo-field--3">总负责人<input name="owner" maxlength="255" list="sysTodoOwners"></label>
<label class="sys-todo-field sys-todo-field--3">责任人<input name="assignee" maxlength="255" list="sysTodoOwners"></label>
<div class="sys-todo-field sys-todo-field--3">
<span>总负责人</span>
<div class="sys-todo-owner-checks" data-sys-todo-owner-options>
@forelse ($todoOwners as $owner)
<label><input type="checkbox" value="{{ $owner }}"><span>{{ $owner }}</span></label>
@empty
<span class="sys-todo-filter__multi-empty">暂无可选总负责人</span>
@endforelse
</div>
<input type="hidden" name="owner" data-sys-todo-owner-value>
</div>
<label class="sys-todo-field sys-todo-field--3">责任人<input name="assignee" maxlength="255" list="sysTodoAssignees"></label>
<label class="sys-todo-field sys-todo-field--3">开始时间<input type="date" name="start_date"></label>
<label class="sys-todo-field sys-todo-field--3">完成时间<input type="date" name="due_date"></label>
<label class="sys-todo-field sys-todo-field--6">待办事项<textarea name="title" maxlength="5000" required></textarea></label>
@ -459,8 +505,7 @@
</div>
</dialog>
<datalist id="sysTodoCategories">@foreach ($todoCategories as $category)<option value="{{ $category }}">@endforeach</datalist>
<datalist id="sysTodoOwners">@foreach ($todoOwners->merge($todoAssignees)->unique()->sort() as $person)<option value="{{ $person }}">@endforeach</datalist>
<datalist id="sysTodoAssignees">@foreach ($todoAssignees as $assignee)<option value="{{ $assignee }}">@endforeach</datalist>
<script>
(function () {
@ -476,9 +521,19 @@
var imagePreview = imageViewer.querySelector('[data-sys-todo-image-preview]');
var imageTitle = imageViewer.querySelector('[data-sys-todo-image-title]');
var imageOpen = imageViewer.querySelector('[data-sys-todo-image-open]');
var categorySelect = dialog.querySelector('[data-sys-todo-category-select]');
var categoryValue = dialog.querySelector('[data-sys-todo-category-value]');
var newCategoryWrap = dialog.querySelector('[data-sys-todo-new-category]');
var newCategoryInput = dialog.querySelector('[data-sys-todo-new-category-input]');
var ownerValue = dialog.querySelector('[data-sys-todo-owner-value]');
var ownerOptionInputs = Array.prototype.slice.call(
dialog.querySelectorAll('[data-sys-todo-owner-options] input[type="checkbox"]')
);
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var currentTodoId = null;
var uploadPrompt = '点击选择、拖入此区域或直接粘贴,支持多张,每张不超过 10 MB。';
var ownerFilter = document.querySelector('[data-sys-todo-owner-filter]');
var ownerSummary = ownerFilter.querySelector('[data-sys-todo-owner-summary]');
var assigneeFilter = document.querySelector('[data-sys-todo-assignee-filter]');
var assigneeSummary = assigneeFilter.querySelector('[data-sys-todo-assignee-summary]');
@ -500,13 +555,68 @@
.join('、');
}
function updateAssigneeSummary() {
function updatePeopleFilterSummary(filter, summary) {
var selected = Array.prototype.slice
.call(assigneeFilter.querySelectorAll('input[name="assignee[]"]:checked'))
.call(filter.querySelectorAll('input[type="checkbox"]:checked'))
.map(function (input) { return input.value; });
assigneeSummary.textContent = selected.length
? '已选 ' + selected.length + ' 人:' + selected.join('、')
: '全部责任人';
summary.textContent = selected.length
? '已选 ' + selected.length + ' 人'
: (filter === ownerFilter ? '全部总负责人' : '全部责任人');
summary.title = selected.join('、');
}
function splitPeople(value) {
return String(value || '')
.split(/[、,,]/)
.map(function (person) { return person.trim(); })
.filter(Boolean);
}
function updateOwnerValue() {
ownerValue.value = ownerOptionInputs
.filter(function (input) { return input.checked; })
.map(function (input) { return input.value; })
.join('、');
}
function setOwnerValue(value) {
var selected = splitPeople(value);
ownerOptionInputs.forEach(function (input) {
input.checked = selected.indexOf(input.value) !== -1;
});
updateOwnerValue();
}
function setCategoryValue(value) {
var normalized = String(value || '').trim();
var hasOption = Array.prototype.some.call(
categorySelect.options,
function (option) { return option.value === normalized && normalized !== '__new__'; }
);
if (normalized !== '' && !hasOption) {
categorySelect.value = '__new__';
newCategoryWrap.hidden = false;
newCategoryInput.required = true;
newCategoryInput.value = normalized;
categoryValue.value = normalized;
return;
}
categorySelect.value = normalized;
newCategoryWrap.hidden = true;
newCategoryInput.required = false;
newCategoryInput.value = '';
categoryValue.value = normalized;
}
function syncCategoryValue() {
var creating = categorySelect.value === '__new__';
newCategoryWrap.hidden = !creating;
newCategoryInput.required = creating;
categoryValue.value = creating
? newCategoryInput.value.trim()
: categorySelect.value;
}
function appendScreenshotFiles(files) {
@ -556,10 +666,10 @@
form.action = currentTodoId ? routes.update.replace('__TODO__', currentTodoId) : routes.store;
title.textContent = currentTodoId ? '编辑待办' : '新增待办';
if (payload) {
setValue('category', payload.category);
setCategoryValue(payload.category);
setValue('section', payload.section);
setValue('title', payload.title);
setValue('owner', payload.owner);
setOwnerValue(payload.owner);
setValue('assignee', payload.assignee);
setValue('start_date', payload.start_date);
setValue('due_date', payload.due_date);
@ -567,6 +677,9 @@
setValue('completed_at', payload.completed_at);
setValue('progress', payload.progress);
setValue('notes', payload.notes);
} else {
setCategoryValue('');
setOwnerValue('');
}
if (!currentTodoId && !(payload && payload.status)) {
setValue('status', '待办');
@ -659,13 +772,30 @@
imageViewer.addEventListener('click', function (event) {
if (event.target === imageViewer) closeImageViewer();
});
categorySelect.addEventListener('change', syncCategoryValue);
newCategoryInput.addEventListener('input', function () {
if (categorySelect.value === '__new__') syncCategoryValue();
});
ownerOptionInputs.forEach(function (input) {
input.addEventListener('change', updateOwnerValue);
});
ownerFilter.querySelectorAll('input[name="owner[]"]').forEach(function (input) {
input.addEventListener('change', function () {
updatePeopleFilterSummary(ownerFilter, ownerSummary);
});
});
assigneeFilter.querySelectorAll('input[name="assignee[]"]').forEach(function (input) {
input.addEventListener('change', updateAssigneeSummary);
input.addEventListener('change', function () {
updatePeopleFilterSummary(assigneeFilter, assigneeSummary);
});
});
document.addEventListener('click', function (event) {
if (!event.target.closest('[data-sys-todo-assignee-filter]')) {
assigneeFilter.removeAttribute('open');
}
if (!event.target.closest('[data-sys-todo-owner-filter]')) {
ownerFilter.removeAttribute('open');
}
});
document.addEventListener('keydown', function (event) {
if (event.target.matches('[data-sys-todo-image-close]') && (event.key === 'Enter' || event.key === ' ')) {

View File

@ -33,8 +33,13 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSeeText('完成时间')
->assertSeeText('在列表底部新增一条')
->assertSee('data-sys-todo-create', false)
->assertSee('data-sys-todo-owner-filter', false)
->assertSee('data-sys-todo-assignee-filter', false)
->assertSee('name="owner[]"', false)
->assertSee('name="assignee[]"', false)
->assertSee('data-sys-todo-category-select', false)
->assertSee('data-sys-todo-new-category-input', false)
->assertSeeText('+ 新增类型')
->assertSee('data-sys-todo-choose-files', false)
->assertSee('id="sysTodoImageViewer"', false);
@ -42,7 +47,7 @@ class SystemizationTodosTest extends FeatureTestCase
'category' => '收入',
'section' => '平台收入核对',
'title' => '核对淘系收入与平台账单差异',
'owner' => '晨曦',
'owner' => '晨曦、人参',
'assignee' => '栎阳',
'start_date' => '2026-10-25',
'due_date' => '2026-10-28',
@ -60,6 +65,7 @@ class SystemizationTodosTest extends FeatureTestCase
$todo = SystemizationTodo::query()->firstOrFail();
$this->assertSame('收入', $todo->category);
$this->assertSame('晨曦、人参', $todo->owner);
$this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status);
$this->assertSame(2, $todo->attachments()->count());
$this->get(route('business.pages.show', [
@ -184,7 +190,12 @@ class SystemizationTodosTest extends FeatureTestCase
->assertDontSee('3、成本事项')
->assertSee('收入事项 A')
->assertSee('收入事项 B');
$pageItems = $pageResponse->viewData('content')['items']->getCollection();
$pageContent = $pageResponse->viewData('content');
$this->assertSame(
['__overdue__', '待办'],
array_column($pageContent['todoStatusOptions']->all(), 'value'),
);
$pageItems = $pageContent['items']->getCollection();
$this->assertSame(
[1, 2],
$pageItems->where('category', '收入')
@ -226,6 +237,16 @@ class SystemizationTodosTest extends FeatureTestCase
$peopleContent['items']->pluck('title')->all(),
);
$ownerResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'owner' => ['常仪', '人参'],
]))->assertOk();
$this->assertEqualsCanonicalizing(
['3、成本事项', '4、逾期事项'],
$ownerResponse->viewData('content')['items']->pluck('title')->all(),
);
$overdueResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',