完善系统化待办逾期与多选筛选 #567

Merged
yueyang merged 1 commits from fix/yueyang-systemization-todo-overdue-multiselect into main 2026-10-25 23:04:11 +08:00
3 changed files with 158 additions and 27 deletions

View File

@ -17,6 +17,8 @@ use Throwable;
class SystemizationTodos extends Controller
{
private const OVERDUE_FILTER = '__overdue__';
public function page(): array
{
$filters = $this->filters();
@ -24,7 +26,11 @@ class SystemizationTodos extends Controller
->with('attachments')
->withCount('attachments');
if ($filters['status'] !== '') {
if ($filters['status'] === self::OVERDUE_FILTER) {
$query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
->whereNotNull('due_date')
->whereDate('due_date', '<', today());
} elseif ($filters['status'] !== '') {
$query->where('status', $filters['status']);
}
if ($filters['category'] !== '') {
@ -33,8 +39,16 @@ class SystemizationTodos extends Controller
if ($filters['owner'] !== '') {
$query->where('owner', 'like', '%' . addcslashes($filters['owner'], '%_\\') . '%');
}
if ($filters['assignee'] !== '') {
$query->where('assignee', 'like', '%' . addcslashes($filters['assignee'], '%_\\') . '%');
if ($filters['assignees'] !== []) {
$query->where(function ($builder) use ($filters): void {
foreach ($filters['assignees'] as $assignee) {
$builder->orWhere(
'assignee',
'like',
'%' . addcslashes($assignee, '%_\\') . '%',
);
}
});
}
if ($filters['start_date'] !== '') {
$query->whereDate('start_date', $filters['start_date']);
@ -56,6 +70,20 @@ 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')
@ -85,6 +113,7 @@ class SystemizationTodos extends Controller
'todoOwners' => $this->peopleOptions('owner'),
'todoAssignees' => $this->peopleOptions('assignee'),
'todoStatuses' => SystemizationTodo::STATUSES,
'todoOverdueFilter' => self::OVERDUE_FILTER,
'todoSummary' => [
'total' => (clone $base)->count(),
'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(),
@ -204,13 +233,24 @@ 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' => in_array(request('status'), SystemizationTodo::STATUSES, true)
? (string) request('status')
'status' => $status === self::OVERDUE_FILTER
|| in_array($status, SystemizationTodo::STATUSES, true)
? $status
: '',
'category' => trim((string) request('category', '')),
'owner' => trim((string) request('owner', '')),
'assignee' => trim((string) request('assignee', '')),
'assignees' => $assignees,
'start_date' => $this->normalizeDateFilter(request('start_date')),
'due_date' => $this->normalizeDateFilter(request('due_date')),
'keyword' => trim((string) request('keyword', '')),

View File

@ -21,6 +21,19 @@
.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-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; }
.sys-todo-filter__multi-menu label { display:flex; flex:none; align-items:center; gap:.45rem; min-height:2rem; padding:.3rem .45rem; border-radius:.35rem; color:#333; font-size:.78rem; cursor:pointer; }
.sys-todo-filter__multi-menu label:hover { background:var(--sys-gold-soft); }
.sys-todo-filter__multi-menu input[type="checkbox"] { flex:0 0 auto; width:1rem; height:1rem; padding:0; accent-color:var(--sys-gold); }
.sys-todo-filter__multi-empty { padding:.65rem; color:#777; font-size:.75rem; text-align:center; }
.sys-todo-filter__actions { display:flex; flex:0 0 auto; gap:.45rem; }
.sys-todo-table-wrap { overflow:auto; border:1px solid var(--sys-border); border-radius:.65rem; background:#fff; }
.sys-todo-table-wrap { max-height:clamp(24rem,58vh,46rem); overscroll-behavior:contain; scrollbar-gutter:stable; }
@ -156,6 +169,7 @@
<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>
@endforeach
@ -172,12 +186,31 @@
<option value="{{ $owner }}" @selected($todoFilters['owner'] === $owner)>{{ $owner }}</option>
@endforeach
</select></label>
<label>责任人<select name="assignee">
<option value="">全部责任人</option>
@foreach ($todoAssignees as $assignee)
<option value="{{ $assignee }}" @selected($todoFilters['assignee'] === $assignee)>{{ $assignee }}</option>
@endforeach
</select></label>
@php
$selectedAssignees = $todoAssignees->filter(
static fn (string $assignee): bool => in_array($assignee, $todoFilters['assignees'], true)
);
$assigneeSummary = $selectedAssignees->isEmpty()
? '全部责任人'
: '已选 ' . $selectedAssignees->count() . ' 人:' . $selectedAssignees->implode('、');
@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>
<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>
@ -188,7 +221,7 @@
</form>
@php
$cleanSectionLabel = static function (?string $value): string {
$cleanDisplayLabel = static function (?string $value): string {
return trim((string) preg_replace(
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
'',
@ -196,7 +229,10 @@
));
};
$sectionLabels = $items
->map(static fn ($groupItem): string => $cleanSectionLabel($groupItem->section))
->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->section))
->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(),
@ -264,7 +300,7 @@
'id' => $item->id,
'category' => $item->category ?? '',
'section' => $sectionLabels[$loop->index] ?? '',
'title' => $item->title,
'title' => $titleLabels[$loop->index] ?? '',
'owner' => $item->owner ?? '',
'assignee' => $item->assignee ?? '',
'start_date' => $item->start_date?->format('Y-m-d') ?? '',
@ -295,8 +331,8 @@
<div class="sys-todo-table__clamp" title="{{ $sectionLabels[$loop->index] ?: '—' }}">{{ $sectionLabels[$loop->index] ?: '—' }}</div>
</td>
@endif
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前列表连续序号">{{ $items->firstItem() + $loop->index }}</span></td>
<td class="sys-todo-table__title"><div class="sys-todo-table__clamp" title="{{ $item->title }}">{{ $item->title }}</div></td>
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前类型内序号">{{ (int) ($item->category_sequence ?? 0) }}</span></td>
<td class="sys-todo-table__title"><div class="sys-todo-table__clamp" title="{{ $titleLabels[$loop->index] }}">{{ $titleLabels[$loop->index] }}</div></td>
<td>{{ $item->owner ?: '—' }}</td>
<td>{{ $item->assignee ?: '—' }}</td>
<td>{{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }}</td>
@ -443,6 +479,8 @@
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var currentTodoId = null;
var uploadPrompt = '点击选择、拖入此区域或直接粘贴,支持多张,每张不超过 10 MB。';
var assigneeFilter = document.querySelector('[data-sys-todo-assignee-filter]');
var assigneeSummary = assigneeFilter.querySelector('[data-sys-todo-assignee-summary]');
function decodePayload(value) {
var binary = atob(value);
@ -462,6 +500,15 @@
.join('、');
}
function updateAssigneeSummary() {
var selected = Array.prototype.slice
.call(assigneeFilter.querySelectorAll('input[name="assignee[]"]:checked'))
.map(function (input) { return input.value; });
assigneeSummary.textContent = selected.length
? '已选 ' + selected.length + ' 人:' + selected.join('、')
: '全部责任人';
}
function appendScreenshotFiles(files) {
var images = Array.prototype.slice.call(files || []).filter(function (file) {
return /^image\/(jpeg|png|webp)$/.test(file.type)
@ -612,6 +659,14 @@
imageViewer.addEventListener('click', function (event) {
if (event.target === imageViewer) closeImageViewer();
});
assigneeFilter.querySelectorAll('input[name="assignee[]"]').forEach(function (input) {
input.addEventListener('change', updateAssigneeSummary);
});
document.addEventListener('click', function (event) {
if (!event.target.closest('[data-sys-todo-assignee-filter]')) {
assigneeFilter.removeAttribute('open');
}
});
document.addEventListener('keydown', function (event) {
if (event.target.matches('[data-sys-todo-image-close]') && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();

View File

@ -28,10 +28,13 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSeeText('从 Excel 导入现有待办')
->assertSeeText('总负责人')
->assertSeeText('责任人')
->assertSeeText('已逾期')
->assertSeeText('开始时间')
->assertSeeText('完成时间')
->assertSeeText('在列表底部新增一条')
->assertSee('data-sys-todo-create', false)
->assertSee('data-sys-todo-assignee-filter', false)
->assertSee('name="assignee[]"', false)
->assertSee('data-sys-todo-choose-files', false)
->assertSee('id="sysTodoImageViewer"', false);
@ -160,23 +163,44 @@ class SystemizationTodosTest extends FeatureTestCase
{
$this->loginAs();
$this->createTodo('收入', '1、平台收入核对', '收入事项 A', '2026-10-26', 1, '晨曦', '栎阳、秋香');
$this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-10-27', 2);
$this->createTodo('成本', '3、退款核销', '成本事项', '2026-10-26', 3, '常仪', '山时');
$this->createTodo('收入', '1、平台收入核对', '1、收入事项 A', '2026-10-26', 1, '晨曦', '栎阳、秋香');
$this->createTodo('收入', '平台收入核对', '2、收入事项 B', '2026-10-27', 2);
$this->createTodo('成本', '3、退款核销', '3、成本事项', '2026-10-26', 3, '常仪', '山时');
$this->createTodo('费用', '4、逾期核对', '4、逾期事项', '2026-10-20', 4, '人参', '玉兔');
$this->get(route('business.pages.show', [
$pageResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))
]));
$pageResponse
->assertOk()
->assertSee('rowspan="2"', false)
->assertSee('<th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th>', false)
->assertSee('title="当前列表连续序号">1</span>', false)
->assertSee('title="当前列表连续序号">2</span>', false)
->assertSee('title="当前类型内序号">1</span>', false)
->assertSee('title="当前类型内序号">2</span>', false)
->assertDontSee('1、平台收入核对')
->assertDontSee('3、退款核销')
->assertDontSee('1、收入事项 A')
->assertDontSee('3、成本事项')
->assertSee('收入事项 A')
->assertSee('收入事项 B');
$pageItems = $pageResponse->viewData('content')['items']->getCollection();
$this->assertSame(
[1, 2],
$pageItems->where('category', '收入')
->pluck('category_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$this->assertSame(
[1],
$pageItems->where('category', '成本')
->pluck('category_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$response = $this->get(route('business.pages.show', [
'section' => 'other',
@ -194,11 +218,23 @@ class SystemizationTodosTest extends FeatureTestCase
$peopleResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'owner' => '晨曦',
'assignee' => '秋香',
'assignee' => ['秋香', '山时'],
]))->assertOk();
$peopleContent = $peopleResponse->viewData('content');
$this->assertSame(['收入事项 A'], $peopleContent['items']->pluck('title')->all());
$this->assertEqualsCanonicalizing(
['1、收入事项 A', '3、成本事项'],
$peopleContent['items']->pluck('title')->all(),
);
$overdueResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'status' => '__overdue__',
]))->assertOk();
$this->assertSame(
['4、逾期事项'],
$overdueResponse->viewData('content')['items']->pluck('title')->all(),
);
}
private function createTodo(