调整系统化待办筛选与列顺序 #560

Merged
yueyang merged 1 commits from feature/yueyang-systemization-todo-filters-order into main 2026-10-25 20:37:14 +08:00
3 changed files with 89 additions and 82 deletions

View File

@ -8,6 +8,7 @@ use App\Models\Business\SystemizationTodoAttachment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@ -19,17 +20,6 @@ class SystemizationTodos extends Controller
public function page(): array
{
$filters = $this->filters();
if (
$filters['date_from'] !== ''
&& $filters['date_to'] !== ''
&& $filters['date_from'] > $filters['date_to']
) {
[$filters['date_from'], $filters['date_to']] = [
$filters['date_to'],
$filters['date_from'],
];
}
$query = SystemizationTodo::query()
->with('attachments')
->withCount('attachments');
@ -41,16 +31,16 @@ class SystemizationTodos extends Controller
$query->where('category', $filters['category']);
}
if ($filters['owner'] !== '') {
$query->where(function ($builder) use ($filters): void {
$builder->where('owner', $filters['owner'])
->orWhere('assignee', $filters['owner']);
});
$query->where('owner', 'like', '%' . addcslashes($filters['owner'], '%_\\') . '%');
}
if ($filters['date_from'] !== '') {
$query->whereDate($filters['date_scope'], '>=', $filters['date_from']);
if ($filters['assignee'] !== '') {
$query->where('assignee', 'like', '%' . addcslashes($filters['assignee'], '%_\\') . '%');
}
if ($filters['date_to'] !== '') {
$query->whereDate($filters['date_scope'], '<=', $filters['date_to']);
if ($filters['start_date'] !== '') {
$query->whereDate('start_date', $filters['start_date']);
}
if ($filters['due_date'] !== '') {
$query->whereDate('due_date', $filters['due_date']);
}
if ($filters['keyword'] !== '') {
$like = '%' . addcslashes($filters['keyword'], '%_\\') . '%';
@ -85,21 +75,6 @@ class SystemizationTodos extends Controller
->distinct()
->orderBy('category')
->pluck('category');
$owners = SystemizationTodo::query()
->whereNotNull('owner')
->where('owner', '<>', '')
->pluck('owner')
->merge(
SystemizationTodo::query()
->whereNotNull('assignee')
->where('assignee', '<>', '')
->pluck('assignee')
)
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
->filter()
->unique()
->sort()
->values();
return [
'view' => 'business.pages.systemization-todos',
@ -107,9 +82,9 @@ class SystemizationTodos extends Controller
'items' => $items,
'todoFilters' => $filters,
'todoCategories' => $categories,
'todoOwners' => $owners,
'todoOwners' => $this->peopleOptions('owner'),
'todoAssignees' => $this->peopleOptions('assignee'),
'todoStatuses' => SystemizationTodo::STATUSES,
'todoDateScopes' => $this->dateScopes(),
'todoSummary' => [
'total' => (clone $base)->count(),
'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(),
@ -229,29 +204,30 @@ class SystemizationTodos extends Controller
private function filters(): array
{
$dateScopes = $this->dateScopes();
$dateScope = (string) request('date_scope', 'due_date');
return [
'status' => in_array(request('status'), SystemizationTodo::STATUSES, true)
? (string) request('status')
: '',
'category' => trim((string) request('category', '')),
'owner' => trim((string) request('owner', '')),
'assignee' => trim((string) request('assignee', '')),
'start_date' => $this->normalizeDateFilter(request('start_date')),
'due_date' => $this->normalizeDateFilter(request('due_date')),
'keyword' => trim((string) request('keyword', '')),
'date_scope' => array_key_exists($dateScope, $dateScopes) ? $dateScope : 'due_date',
'date_from' => $this->normalizeDateFilter(request('date_from')),
'date_to' => $this->normalizeDateFilter(request('date_to')),
];
}
private function dateScopes(): array
private function peopleOptions(string $column): Collection
{
return [
'due_date' => '完成时间',
'start_date' => '开始时间',
'created_at' => '新增时间',
];
return SystemizationTodo::query()
->whereNotNull($column)
->where($column, '<>', '')
->pluck($column)
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
->filter()
->unique()
->sort()
->values();
}
private function normalizeDateFilter(mixed $value): string

View File

@ -24,24 +24,23 @@
.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; }
.sys-todo-table { width:100%; min-width:88rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.sys-todo-table { width:100%; min-width:80rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.sys-todo-table th { position:sticky; top:0; z-index:4; padding:.55rem .48rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
.sys-todo-table td { padding:.45rem .48rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; vertical-align:top; }
.sys-todo-table tbody tr { cursor:pointer; }
.sys-todo-table tbody tr:hover td { background:#fdfbf6; }
.sys-todo-table tbody tr:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; }
.sys-todo-table th:first-child, .sys-todo-table td:first-child { position:sticky; left:0; z-index:3; width:4rem; min-width:4rem; text-align:center; box-shadow:1px 0 0 #e5ddcf; }
.sys-todo-table th:first-child { z-index:6; background:#f3ece0; }
.sys-todo-table__index-cell { width:3.6rem; min-width:3.6rem; text-align:center; }
.sys-todo-table th:last-child, .sys-todo-table td:last-child { position:sticky; right:0; z-index:3; width:5.8rem; min-width:5.8rem; background:#fff; box-shadow:-1px 0 0 #e5ddcf; }
.sys-todo-table th:last-child { z-index:6; background:#f3ece0; }
.sys-todo-table tbody tr:hover td:first-child, .sys-todo-table tbody tr:hover td:last-child { background:#fdfbf6; }
.sys-todo-table tbody tr:hover td:last-child { background:#fdfbf6; }
.sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; }
.sys-todo-table__group { vertical-align:middle; background:#fcfaf5; font-weight:600; }
.sys-todo-table__section { min-width:14rem; max-width:20rem; }
.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:20rem; max-width:27rem; font-weight:600; }
.sys-todo-table__progress { min-width:15rem; max-width:23rem; }
.sys-todo-table__notes { min-width:11rem; max-width:18rem; }
.sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; }
.sys-todo-table__progress { min-width:12rem; max-width:20rem; }
.sys-todo-table__notes { min-width:9rem; max-width:16rem; }
.sys-todo-table__clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:3; }
.sys-todo-table__section .sys-todo-table__clamp { -webkit-line-clamp:2; }
.sys-todo-badge { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; }
@ -167,19 +166,20 @@
<option value="{{ $category }}" @selected($todoFilters['category'] === $category)>{{ $category }}</option>
@endforeach
</select></label>
<label>责任人<select name="owner">
<option value="">全部责任人</option>
<label>总负责人<select name="owner">
<option value="">全部总负责人</option>
@foreach ($todoOwners as $owner)
<option value="{{ $owner }}" @selected($todoFilters['owner'] === $owner)>{{ $owner }}</option>
@endforeach
</select></label>
<label>时间类型<select name="date_scope" data-sys-todo-filter-date-scope>
@foreach ($todoDateScopes as $value => $label)
<option value="{{ $value }}" @selected($todoFilters['date_scope'] === $value)>{{ $label }}</option>
<label>责任人<select name="assignee">
<option value="">全部责任人</option>
@foreach ($todoAssignees as $assignee)
<option value="{{ $assignee }}" @selected($todoFilters['assignee'] === $assignee)>{{ $assignee }}</option>
@endforeach
</select></label>
<label>从<input type="date" name="date_from" value="{{ $todoFilters['date_from'] }}"></label>
<label>至<input type="date" name="date_to" value="{{ $todoFilters['date_to'] }}"></label>
<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>
<div class="sys-todo-filter__actions">
<button type="submit" class="sys-todo-page__button sys-todo-page__button--primary">筛选</button>
@ -188,13 +188,29 @@
</form>
@php
$cleanSectionLabel = static function (?string $value): string {
return trim((string) preg_replace(
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
'',
(string) $value,
));
};
$sectionLabels = $items
->map(static fn ($groupItem): string => $cleanSectionLabel($groupItem->section))
->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 ?? ''))
. "\0"
. ($sectionLabels[$index] ?? ''))->all(),
];
$groupRowspans = ['category' => [], 'section' => []];
foreach (['category', 'section'] as $groupField) {
$start = 0;
$previous = null;
$count = 0;
foreach ($items as $index => $groupItem) {
$current = trim((string) ($groupItem->{$groupField} ?? ''));
$current = (string) ($groupValues[$groupField][$index] ?? '');
if ($index === 0) {
$start = 0;
$previous = $current;
@ -221,13 +237,14 @@
}
}
$lastItem = $items->last();
$lastSectionLabel = $items->isNotEmpty() ? ($sectionLabels[$items->count() - 1] ?? '') : '';
@endphp
<div class="sys-todo-table-wrap" tabindex="0" aria-label="待办列表,可滚动查看全部内容">
<table class="sys-todo-table" aria-label="财务系统化待办清单">
<thead>
<tr>
<th scope="col">序号</th><th scope="col">类型</th><th scope="col">内容</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
<th scope="col">类型</th><th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
<th scope="col">开始时间</th><th scope="col">完成时间</th><th scope="col">状态</th><th scope="col">完成情况</th><th scope="col">完成截图</th><th scope="col">备注</th><th scope="col">操作</th>
</tr>
</thead>
@ -246,7 +263,7 @@
$todoPayload = [
'id' => $item->id,
'category' => $item->category ?? '',
'section' => $item->section ?? '',
'section' => $sectionLabels[$loop->index] ?? '',
'title' => $item->title,
'owner' => $item->owner ?? '',
'assignee' => $item->assignee ?? '',
@ -270,15 +287,15 @@
$sectionSpan = $groupRowspans['section'][$loop->index] ?? 1;
@endphp
<tr tabindex="0" data-sys-todo-row-edit="{{ $todoPayloadEncoded }}" title="点击整行直接编辑">
<td><span class="sys-todo-table__index" title="当前列表连续序号">{{ $items->firstItem() + $loop->index }}</span></td>
@if ($categorySpan !== 0)
<td class="sys-todo-table__group" @if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif>{{ $item->category ?: '—' }}</td>
@endif
@if ($sectionSpan !== 0)
<td class="sys-todo-table__group sys-todo-table__section" @if ($sectionSpan > 1) rowspan="{{ $sectionSpan }}" @endif>
<div class="sys-todo-table__clamp" title="{{ $item->section ?: '—' }}">{{ $item->section ?: '—' }}</div>
<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>{{ $item->owner ?: '—' }}</td>
<td>{{ $item->assignee ?: '—' }}</td>
@ -329,10 +346,10 @@
</div>
<div class="sys-todo-table__add">
<span>点击表内任意事项可直接编辑;表格在框内滚动,表头、序号和操作列保持可见。</span>
<span>点击表内任意事项可直接编辑;表格在框内滚动,表头和操作列保持可见。</span>
<button type="button" data-sys-todo-create
data-sys-todo-default-category="{{ $lastItem?->category ?? '' }}"
data-sys-todo-default-section="{{ $lastItem?->section ?? '' }}">
data-sys-todo-default-section="{{ $lastSectionLabel }}">
<span class="material-icons-outlined" aria-hidden="true">playlist_add</span>在列表底部新增一条
</button>
</div>
@ -407,7 +424,7 @@
</dialog>
<datalist id="sysTodoCategories">@foreach ($todoCategories as $category)<option value="{{ $category }}">@endforeach</datalist>
<datalist id="sysTodoOwners">@foreach ($todoOwners as $owner)<option value="{{ $owner }}">@endforeach</datalist>
<datalist id="sysTodoOwners">@foreach ($todoOwners->merge($todoAssignees)->unique()->sort() as $person)<option value="{{ $person }}">@endforeach</datalist>
<script>
(function () {

View File

@ -26,7 +26,10 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSeeText('财务系统化待办')
->assertSeeText('新增待办')
->assertSeeText('从 Excel 导入现有待办')
->assertSeeText('时间类型')
->assertSeeText('总负责人')
->assertSeeText('责任人')
->assertSeeText('开始时间')
->assertSeeText('完成时间')
->assertSeeText('在列表底部新增一条')
->assertSee('data-sys-todo-create', false)
->assertSee('data-sys-todo-choose-files', false)
@ -157,9 +160,9 @@ class SystemizationTodosTest extends FeatureTestCase
{
$this->loginAs();
$this->createTodo('收入', '平台收入核对', '收入事项 A', '2026-10-26', 1);
$this->createTodo('收入', '1、平台收入核对', '收入事项 A', '2026-10-26', 1, '晨曦', '栎阳、秋香');
$this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-10-27', 2);
$this->createTodo('成本', '退款核销', '成本事项', '2026-10-26', 3);
$this->createTodo('成本', '3、退款核销', '成本事项', '2026-10-26', 3, '常仪', '山时');
$this->get(route('business.pages.show', [
'section' => 'other',
@ -167,26 +170,35 @@ class SystemizationTodosTest extends FeatureTestCase
]))
->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)
->assertDontSee('1、平台收入核对')
->assertDontSee('3、退款核销')
->assertSee('收入事项 A')
->assertSee('收入事项 B');
$response = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'date_scope' => 'due_date',
'date_from' => '2026-10-26',
'date_to' => '2026-10-26',
'due_date' => '2026-10-26',
]));
$response->assertOk()->assertSee('收入事项 A')->assertSee('成本事项');
$content = $response->viewData('content');
$this->assertNotContains('收入事项 B', $content['items']->pluck('title')->all());
$this->assertSame([
'date_from' => '2026-10-26',
'date_to' => '2026-10-26',
], array_intersect_key($content['todoFilters'], array_flip(['date_from', 'date_to'])));
'due_date' => '2026-10-26',
], array_intersect_key($content['todoFilters'], array_flip(['due_date'])));
$peopleResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'owner' => '晨曦',
'assignee' => '秋香',
]))->assertOk();
$peopleContent = $peopleResponse->viewData('content');
$this->assertSame(['收入事项 A'], $peopleContent['items']->pluck('title')->all());
}
private function createTodo(
@ -195,14 +207,16 @@ class SystemizationTodosTest extends FeatureTestCase
string $title,
string $dueDate,
int $sortOrder,
string $owner = '晨曦',
string $assignee = '栎阳',
): SystemizationTodo {
return SystemizationTodo::query()->create([
'company_id' => (int) company_id(),
'category' => $category,
'section' => $section,
'title' => $title,
'owner' => '晨曦',
'assignee' => '栎阳',
'owner' => $owner,
'assignee' => $assignee,
'start_date' => '2026-10-25',
'due_date' => $dueDate,
'status' => SystemizationTodo::STATUS_PENDING,