完善系统化待办筛选与展示 #664

Merged
yueyang merged 1 commits from feature/yueyang-systemization-todo-layout-fixes into main 2026-10-26 22:56:32 +08:00
5 changed files with 514 additions and 115 deletions

View File

@ -295,7 +295,7 @@ class SystemizationTodoWorkbookImporter
[$finishDate, $finishNote] = $this->dateParts($finishText);
$progress = $this->cell($cells, $columns['progress'] ?? null);
$notes = $this->cell($cells, $columns['notes'] ?? null);
$status = $this->status($progress, $startText, $finishText);
$status = $this->status($progress);
foreach ($titles as $offset => $title) {
$titleIndex = $offset + 1;
@ -674,20 +674,12 @@ class SystemizationTodoWorkbookImporter
return [null, mb_substr($value, 0, 100)];
}
private function status(string $progress, string $startNote, string $finishNote): string
private function status(string $progress): string
{
if (str_contains($progress, '已完成')) {
return SystemizationTodo::STATUS_COMPLETED;
}
if ($progress !== '') {
return str_contains($progress, '延期')
? SystemizationTodo::STATUS_DELAYED
: SystemizationTodo::STATUS_IN_PROGRESS;
}
if (str_contains($startNote, '延期') || str_contains($finishNote, '延期')) {
return SystemizationTodo::STATUS_DELAYED;
}
return SystemizationTodo::STATUS_PENDING;
return SystemizationTodo::STATUS_INCOMPLETE;
}
}

View File

@ -28,10 +28,12 @@ class SystemizationTodos extends Controller
->withCount('attachments');
if ($filters['status'] === self::OVERDUE_FILTER) {
$query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereNotNull('due_date')
->whereDate('due_date', '<', today());
} elseif ($filters['status'] !== '') {
} elseif ($filters['status'] === SystemizationTodo::STATUS_INCOMPLETE) {
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED);
} elseif ($filters['status'] === SystemizationTodo::STATUS_COMPLETED) {
$query->where('status', $filters['status']);
}
if ($filters['category'] !== '') {
@ -103,23 +105,28 @@ class SystemizationTodos extends Controller
$todo->setAttribute('section_display', $sequence['section_display']);
$todo->setAttribute('type_count', $sequence['type_count']);
$todo->setAttribute('content_count', $sequence['content_count']);
$todo->setAttribute('status_label', $todo->statusLabel());
$reminder = $todo->reminder();
$todo->setAttribute('reminder_label', $reminder['label']);
$todo->setAttribute('reminder_tone', $reminder['tone']);
});
$items = $items->sortBy('hierarchy_order')->values();
$positionGroups = $this->positionGroups($sequenceMap);
$base = SystemizationTodo::query();
$totalCount = (clone $base)->count();
$completedCount = (clone $base)
->where('status', SystemizationTodo::STATUS_COMPLETED)
->count();
$incompleteCount = max(0, $totalCount - $completedCount);
$categories = SystemizationTodo::query()
->whereNotNull('category')
->where('category', '<>', '')
->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])
->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereNotNull('due_date')
->whereDate('due_date', '<', today())
->count();
@ -131,12 +138,10 @@ class SystemizationTodos extends Controller
]);
}
foreach (SystemizationTodo::STATUSES as $status) {
if ((int) $statusCounts->get($status, 0) > 0) {
$statusOptions->push([
'value' => $status,
'label' => $status,
]);
}
$statusOptions->push([
'value' => $status,
'label' => $status,
]);
}
return [
@ -151,11 +156,14 @@ class SystemizationTodos extends Controller
'todoAssignees' => $this->peopleOptions('assignee'),
'todoStatuses' => SystemizationTodo::STATUSES,
'todoStatusOptions' => $statusOptions,
'todoReturnFilters' => base64_encode(json_encode(
$filters,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
)),
'todoSummary' => [
'total' => (clone $base)->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),
'total' => $totalCount,
'incomplete' => $incompleteCount,
'completed' => $completedCount,
'overdue' => $overdueCount,
],
'todoRoutes' => [
@ -333,6 +341,7 @@ class SystemizationTodos extends Controller
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
$this->normalizePendingDates($request, $data);
$position = max(1, (int) ($data['position'] ?? 1));
unset($data['position']);
$data['company_id'] = (int) company_id();
@ -506,6 +515,7 @@ class SystemizationTodos extends Controller
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
{
$data = $this->validated($request);
$this->normalizePendingDates($request, $data);
$targetPosition = max(0, (int) ($data['position'] ?? 0));
unset($data['position']);
$data['updated_by'] = user_id();
@ -596,22 +606,30 @@ class SystemizationTodos extends Controller
private function filters(): array
{
$status = (string) request('status', '');
return [
'status' => $status === self::OVERDUE_FILTER
|| in_array($status, SystemizationTodo::STATUSES, true)
? $status
: '',
'status' => $this->normalizeStatusValue(request('status')),
'category' => trim((string) request('category', '')),
'owners' => $this->peopleFilter('owner'),
'assignees' => $this->peopleFilter('assignee'),
'start_date' => $this->normalizeDateFilter(request('start_date')),
'due_date' => $this->normalizeDateFilter(request('due_date')),
'start_date' => $this->normalizeDateValue(request('start_date')),
'due_date' => $this->normalizeDateValue(request('due_date')),
'keyword' => trim((string) request('keyword', '')),
];
}
private function normalizeStatusValue(mixed $value): string
{
$status = trim((string) $value);
if (in_array($status, SystemizationTodo::LEGACY_INCOMPLETE_STATUSES, true)) {
return SystemizationTodo::STATUS_INCOMPLETE;
}
return $status === self::OVERDUE_FILTER
|| in_array($status, SystemizationTodo::STATUSES, true)
? $status
: '';
}
private function peopleFilter(string $key): array
{
return collect((array) request($key, []))
@ -661,7 +679,7 @@ class SystemizationTodos extends Controller
return $options;
}
private function normalizeDateFilter(mixed $value): string
private function normalizeDateValue(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
@ -686,8 +704,10 @@ class SystemizationTodos extends Controller
'assignee' => ['nullable', 'string', 'max:255'],
'start_date' => ['nullable', 'date_format:Y-m-d'],
'start_note' => ['nullable', 'string', 'max:50'],
'start_pending' => ['nullable', 'boolean'],
'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'due_note' => ['nullable', 'string', 'max:50'],
'due_pending' => ['nullable', 'boolean'],
'status' => ['required', Rule::in(SystemizationTodo::STATUSES)],
'completed_at' => ['nullable', 'date_format:Y-m-d'],
'progress' => ['nullable', 'string', 'max:10000'],
@ -699,6 +719,19 @@ class SystemizationTodos extends Controller
]);
}
private function normalizePendingDates(Request $request, array &$data): void
{
foreach (['start', 'due'] as $prefix) {
if ($request->boolean($prefix . '_pending')) {
$data[$prefix . '_date'] = null;
$data[$prefix . '_note'] = '待定';
} elseif (!empty($data[$prefix . '_date'])) {
$data[$prefix . '_note'] = null;
}
unset($data[$prefix . '_pending']);
}
}
private function normalizeCompletionDates(array &$data): void
{
$completed = ($data['status'] ?? '') === SystemizationTodo::STATUS_COMPLETED;
@ -743,6 +776,7 @@ class SystemizationTodos extends Controller
'section' => 'other',
'page' => 'systemization-todos',
];
$parameters = array_merge($parameters, $this->returnedFilters());
if ($focus) {
$parameters['highlight'] = $focus->id;
}
@ -751,4 +785,51 @@ class SystemizationTodos extends Controller
->route('business.pages.show', $parameters)
->with($type === 'success' ? 'success' : 'error', $message);
}
private function returnedFilters(): array
{
$encoded = request('return_filters');
if (!is_string($encoded) || $encoded === '') {
return [];
}
$json = base64_decode($encoded, true);
$filters = $json === false ? null : json_decode($json, true);
if (!is_array($filters)) {
return [];
}
$category = is_string($filters['category'] ?? null)
? trim($filters['category'])
: '';
$keyword = is_string($filters['keyword'] ?? null)
? trim($filters['keyword'])
: '';
$owners = collect((array) ($filters['owners'] ?? []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
$assignees = collect((array) ($filters['assignees'] ?? []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
return array_filter([
'status' => $this->normalizeStatusValue($filters['status'] ?? null),
'category' => $category,
'owner' => $owners,
'assignee' => $assignees,
'start_date' => $this->normalizeDateValue($filters['start_date'] ?? null),
'due_date' => $this->normalizeDateValue($filters['due_date'] ?? null),
'keyword' => $keyword,
], static fn (mixed $value): bool => $value !== '' && $value !== []);
}
}

View File

@ -7,21 +7,24 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class SystemizationTodo extends Model
{
public const STATUS_PENDING = '待办';
public const STATUS_IN_PROGRESS = '进行中';
public const STATUS_INCOMPLETE = '未完成';
public const STATUS_COMPLETED = '已完成';
public const STATUS_DELAYED = '已延期';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_IN_PROGRESS,
self::STATUS_INCOMPLETE,
self::STATUS_COMPLETED,
self::STATUS_DELAYED,
];
public const LEGACY_INCOMPLETE_STATUSES = ['待办', '进行中', '已延期'];
// Keep old constant names for imports and existing callers without rewriting data.
public const STATUS_PENDING = self::STATUS_INCOMPLETE;
public const STATUS_IN_PROGRESS = self::STATUS_INCOMPLETE;
public const STATUS_DELAYED = self::STATUS_INCOMPLETE;
protected $table = 'business_systemization_todos';
protected $fillable = [
@ -65,4 +68,36 @@ class SystemizationTodo extends Model
{
return $this->status === self::STATUS_COMPLETED;
}
public function statusLabel(): string
{
return $this->isCompleted() ? self::STATUS_COMPLETED : self::STATUS_INCOMPLETE;
}
public function reminder(): array
{
if ($this->isCompleted()) {
return ['label' => '已完成', 'tone' => 'muted'];
}
if ($this->due_date === null) {
return ['label' => '时间待定', 'tone' => 'muted'];
}
$today = today()->startOfDay();
$dueDate = $this->due_date->copy()->startOfDay();
if ($dueDate->lt($today)) {
return [
'label' => '逾期 ' . $dueDate->diffInDays($today) . ' 天',
'tone' => 'danger',
];
}
if ($dueDate->equalTo($today)) {
return ['label' => '今天到期', 'tone' => 'warning'];
}
return [
'label' => '剩余 ' . $today->diffInDays($dueDate) . ' 天',
'tone' => 'info',
];
}
}

View File

@ -11,7 +11,7 @@
.sys-todo-page__button--primary:hover { background:#6a5129; color:#fff; }
.sys-todo-page__button .material-icons-outlined { font-size:1rem; }
.sys-todo-page__intro { margin:.35rem 0 0; color:var(--sys-muted); font-size:.75rem; line-height:1.6; }
.sys-todo-summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.55rem; margin:.8rem 0; }
.sys-todo-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.55rem; margin:.8rem 0; }
.sys-todo-summary__item { display:flex; align-items:center; justify-content:space-between; gap:.6rem; padding:.5rem .7rem; border:1px solid #e6dfd2; border-radius:.5rem; background:#fffdf9; }
.sys-todo-summary__label { color:var(--sys-muted); font-size:.72rem; }
.sys-todo-summary__value { color:#171717; font-size:1rem; font-weight:700; font-variant-numeric:tabular-nums; }
@ -38,42 +38,60 @@
.sys-todo-filter__actions { display:flex; flex:0 0 auto; gap:.45rem; }
.sys-todo-table-wrap { overflow-x:auto; overflow-y:visible; border:1px solid var(--sys-border); border-radius:.65rem; background:#fff; scrollbar-width:none; }
.sys-todo-table-wrap::-webkit-scrollbar { height:0; }
.sys-todo-table { width:100%; min-width:74rem; 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 th:first-child { left:0; z-index:8; width:5.4rem; min-width:5.4rem; text-align:center; box-shadow:1px 0 0 #e5ddcf; }
.sys-todo-table td { padding:.72rem .6rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; line-height:1.6; vertical-align:top; }
.sys-todo-table { width:108rem; min-width:108rem; table-layout:fixed; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.sys-todo-table th { position:sticky; top:0; z-index:4; padding:.55rem .7rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
.sys-todo-table th:first-child { left:0; z-index:8; width:var(--sys-todo-type-width,5.4rem); text-align:center; box-shadow:1px 0 0 #e5ddcf; }
.sys-todo-table td { height:3.5rem; padding:.72rem .7rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; line-height:1.5; vertical-align:middle; }
.sys-todo-table tbody tr { height:3.5rem; }
.sys-todo-table tbody tr:hover td { background:#fdfbf6; }
.sys-todo-table tbody tr.is-highlighted td { background:#fff4cf !important; }
.sys-todo-table [data-sys-todo-row-edit], .sys-todo-table [data-sys-todo-group-edit] { cursor:pointer; }
.sys-todo-table [data-sys-todo-row-edit]:hover, .sys-todo-table [data-sys-todo-group-edit]:hover { background:#fff4cf; }
.sys-todo-table [data-sys-todo-row-edit]:focus-visible, .sys-todo-table [data-sys-todo-group-edit]:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; }
.sys-todo-table__index-cell { width:4.8rem; min-width:4.8rem; text-align:center; }
.sys-todo-table__index-cell { text-align:center; }
.sys-todo-table__index-actions { display:inline-flex; align-items:center; justify-content:center; gap:.32rem; }
.sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; }
.sys-todo-table__insert-after { display:inline-flex; align-items:center; justify-content:center; width:1.45rem; height:1.45rem; padding:0; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); cursor:pointer; }
.sys-todo-table__insert-after:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); color:#6a5129; }
.sys-todo-table__insert-after:focus-visible { outline:2px solid var(--sys-gold); outline-offset:2px; }
.sys-todo-table__insert-after .material-icons-outlined { font-size:.95rem; line-height:1; }
.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 { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.5; 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; white-space:pre-line; overflow-wrap:anywhere; }
.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 { position:sticky; left:0; z-index:3; width:var(--sys-todo-type-width,5.4rem); text-align:center; white-space:normal; 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; }
.sys-todo-table__progress { min-width:12rem; max-width:20rem; }
.sys-todo-table__notes { min-width:9rem; max-width:16rem; }
.sys-todo-table__title, .sys-todo-table__progress-notes { line-height:1.5; white-space:pre-line; overflow-wrap:anywhere; }
.sys-todo-table__title { font-weight:600; }
.sys-todo-table__inline-add { display:inline-flex; align-items:center; justify-content:center; gap:.15rem; min-height:1.65rem; margin-top:.45rem; padding:0 .45rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:#6d5630; font:inherit; font-size:.68rem; font-weight:500; white-space:nowrap; cursor:pointer; }
.sys-todo-table__inline-add:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); color:#6a5129; }
.sys-todo-table__inline-add:focus-visible { outline:2px solid var(--sys-gold); outline-offset:2px; }
.sys-todo-table__inline-actions { display:flex; flex-wrap:wrap; justify-content:center; gap:.25rem; }
.sys-todo-table__inline-actions .sys-todo-table__inline-add { margin-top:.3rem; }
.sys-todo-table__progress-notes { display:grid; gap:.35rem; }
.sys-todo-table__detail { display:grid; gap:.15rem; }
.sys-todo-table__detail-label { color:#856632; font-size:.66rem; font-weight:600; }
.sys-todo-table__reminder { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; }
.sys-todo-table__reminder.is-danger { border-color:#e7c3c3; background:#fff3f3; color:#b80000; }
.sys-todo-table__reminder.is-warning { border-color:#e5c98f; background:#fff8e8; color:#935f07; }
.sys-todo-table__reminder.is-info { border-color:#b5cad8; background:#f3f8fb; color:#006395; }
.sys-todo-table__reminder.is-muted { color:#68625a; }
.sys-todo-table__col-resizer { position:absolute; top:0; right:-4px; z-index:12; width:9px; height:100%; padding:0; border:0; background:transparent; cursor:col-resize; touch-action:none; }
.sys-todo-table__col-resizer::after { position:absolute; top:20%; right:4px; width:1px; height:60%; background:#bfb3a0; content:""; }
.sys-todo-table__col-resizer:hover::after, .sys-todo-table__col-resizer:focus-visible::after { top:0; width:2px; height:100%; background:var(--sys-gold); }
.sys-todo-table__col-resizer:focus-visible { outline:none; }
.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; }
.sys-todo-badge--progress { border-color:#dbbf7a; background:#fff7df; color:#935f07; }
.sys-todo-badge--incomplete { border-color:#dbbf7a; background:#fff7df; color:#935f07; }
.sys-todo-badge--completed { border-color:#bad0ac; background:#f2f8ed; color:#53793e; }
.sys-todo-badge--delayed { border-color:#e7c3c3; background:#fff3f3; color:#b80000; }
.sys-todo-badge--overdue { border-color:#e7c3c3; color:#b80000; }
.sys-todo-shots { display:flex; flex-wrap:wrap; gap:.3rem; max-width:13rem; }
.sys-todo-shots button { display:block; width:4.25rem; height:3.2rem; padding:0; border:1px solid #ddd4c4; border-radius:.35rem; overflow:hidden; background:#faf8f3; cursor:zoom-in; }
.sys-todo-shots img { width:100%; height:100%; object-fit:cover; }
.sys-todo-table__add { margin-top:1rem; padding:.55rem .7rem; border:1px dashed #d8cdb8; border-radius:.55rem; background:#fcfaf5; color:#666; font-size:.72rem; }
.sys-todo-table__add { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.5rem .75rem; margin-top:1rem; padding:.55rem .7rem; border:1px dashed #d8cdb8; border-radius:.55rem; background:#fcfaf5; color:#666; font-size:.72rem; }
.sys-todo-table__add-copy { min-width:16rem; flex:1 1 30rem; }
.sys-todo-table__reset-widths { min-height:1.8rem; padding:0 .55rem; border:1px solid var(--sys-border); border-radius:.4rem; background:#fff; color:#51483c; font:inherit; font-size:.7rem; cursor:pointer; }
.sys-todo-table__reset-widths:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); }
.sys-todo-table__footer-add { background:#fcfaf5; text-align:center; }
.sys-todo-table__footer-note { padding-left:1rem; color:#666; font-size:.7rem; font-weight:400; text-align:left; }
.sys-todo-scrollrail { position:fixed; bottom:.85rem; z-index:35; height:1.15rem; padding:2px; border:1px solid #d8cdb8; border-radius:999px; background:rgba(243,236,224,.96); box-shadow:0 5px 18px rgba(23,23,23,.16); opacity:0; pointer-events:none; overflow-x:scroll; overflow-y:hidden; transition:opacity 150ms ease; }
.sys-todo-scrollrail.is-visible { opacity:1; pointer-events:auto; }
.sys-todo-scrollrail__spacer { height:1px; }
@ -96,6 +114,8 @@
.sys-todo-dialog__body { max-height:calc(90vh - 9rem); overflow:auto; padding:1rem 1.25rem; }
.sys-todo-form-grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:.75rem 1rem; }
.sys-todo-field { display:grid; gap:.3rem; min-width:0; color:#595959; font-size:.75rem; }
.sys-todo-combined-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.65rem; padding:.65rem; border:1px solid var(--sys-border); border-radius:.55rem; background:#fcfaf5; }
.sys-todo-combined-grid label { display:grid; gap:.3rem; min-width:0; }
.sys-todo-field--2 { grid-column:span 2; }
.sys-todo-field--3 { grid-column:span 3; }
.sys-todo-field--4 { grid-column:span 4; }
@ -165,9 +185,9 @@
.sys-todo-image-dialog__body img { max-width:100%; max-height:100%; object-fit:contain; }
.sys-todo-image-dialog__foot { display:flex; justify-content:flex-end; padding:.55rem .85rem; background:#1d1d1f; }
.sys-todo-image-dialog__foot a { color:#f0e4bf; font-size:.75rem; }
@media (max-width:1300px) { .sys-todo-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media (max-width:1300px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:900px) { .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:9rem; } .sys-todo-filter__actions { width:100%; } .sys-todo-filter__actions .sys-todo-page__button { flex:1; } }
@media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } }
@media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } .sys-todo-combined-grid { grid-column:span 12; grid-template-columns:1fr; } }
</style>
<div class="biz-card-head">
@ -177,12 +197,7 @@
<span class="material-icons-outlined" aria-hidden="true">checklist</span>
<span>财务系统化待办</span>
</div>
<p class="sys-todo-page__intro">日常过会使用。新增事项、跟进责任人和完成情况,截图直接附在该事项下。</p>
</div>
<div class="sys-todo-page__head-actions">
<button type="button" class="sys-todo-page__button sys-todo-page__button--primary" data-sys-todo-create>
<span class="material-icons-outlined" aria-hidden="true">add</span>新增待办
</button>
<p class="sys-todo-page__intro">日常过会使用。在类型、内容或序号旁直接添加,筛选修改后仍保持当前筛选结果。</p>
</div>
</div>
</div>
@ -196,8 +211,7 @@
<div class="sys-todo-summary" aria-label="系统化待办统计">
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">全部</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['total']) }}</div></div>
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">待办</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['pending']) }}</div></div>
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">进行中</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['in_progress']) }}</div></div>
<div class="sys-todo-summary__item is-warning"><div class="sys-todo-summary__label">未完成</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['incomplete']) }}</div></div>
<div class="sys-todo-summary__item is-success"><div class="sys-todo-summary__label">已完成</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['completed']) }}</div></div>
<div class="sys-todo-summary__item is-warning"><div class="sys-todo-summary__label">已逾期</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['overdue']) }}</div></div>
</div>
@ -206,6 +220,7 @@
<summary>从 Excel 同步待办(新增并更新工作内容,保留完成截图)</summary>
<form method="POST" action="{{ $todoRoutes['import'] }}" enctype="multipart/form-data">
@csrf
<input type="hidden" name="return_filters" value="{{ $todoReturnFilters }}">
<label>待办 Excel<input type="file" name="source" accept=".xlsx" required></label>
<button type="submit" class="sys-todo-page__button">同步</button>
</form>
@ -337,29 +352,48 @@
@endphp
<div class="sys-todo-table__add">
共 {{ number_format($items->count()) }} 条,已全部显示。点击序号旁的+可在该条之后插入,原后续序号自动顺延;点击待办事项编辑详情,点击类型或内容可单独修改该组名称。
<span class="sys-todo-table__add-copy">共 {{ number_format($items->count()) }} 条,已全部显示。在类型、内容下方的+直接新增;拖动表头分隔线可调整列宽,双击分隔线可恢复该列。</span>
<button type="button" class="sys-todo-table__reset-widths" data-sys-todo-reset-columns>恢复全部默认列宽</button>
</div>
<div class="sys-todo-table-wrap" tabindex="0" aria-label="待办列表,可横向滚动查看全部内容" data-sys-todo-table-wrap>
<table class="sys-todo-table" aria-label="财务系统化待办清单">
<table class="sys-todo-table" aria-label="财务系统化待办清单" style="--sys-todo-type-width:94px">
<colgroup>
<col data-sys-todo-col="type" data-default-width="94" data-min-width="72" style="width:94px">
<col data-sys-todo-col="section" data-default-width="212" data-min-width="120" style="width:212px">
<col data-sys-todo-col="sequence" data-default-width="94" data-min-width="80" style="width:94px">
<col data-sys-todo-col="title" data-default-width="330" data-min-width="180" style="width:330px">
<col data-sys-todo-col="owner" data-default-width="110" data-min-width="80" style="width:110px">
<col data-sys-todo-col="assignee" data-default-width="110" data-min-width="80" style="width:110px">
<col data-sys-todo-col="start_date" data-default-width="112" data-min-width="88" style="width:112px">
<col data-sys-todo-col="due_date" data-default-width="112" data-min-width="88" style="width:112px">
<col data-sys-todo-col="reminder" data-default-width="116" data-min-width="96" style="width:116px">
<col data-sys-todo-col="status" data-default-width="92" data-min-width="80" style="width:92px">
<col data-sys-todo-col="progress_notes" data-default-width="260" data-min-width="180" style="width:260px">
<col data-sys-todo-col="screenshots" data-default-width="110" data-min-width="90" style="width:110px">
</colgroup>
<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" data-sys-todo-column="type">类型<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="type" aria-label="调整类型列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="section">内容<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="section" aria-label="调整内容列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="sequence">待办事项序号<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="sequence" aria-label="调整序号列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="title">待办事项<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="title" aria-label="调整待办事项列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="owner">总负责人<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="owner" aria-label="调整总负责人列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="assignee">责任人<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="assignee" aria-label="调整责任人列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="start_date">开始时间<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="start_date" aria-label="调整开始时间列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="due_date">完成时间<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="due_date" aria-label="调整完成时间列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="reminder">提醒<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="reminder" aria-label="调整提醒列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="status">状态<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="status" aria-label="调整状态列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="progress_notes">完成情况 / 备注<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="progress_notes" aria-label="调整完成情况和备注列宽" title="拖动调整,双击恢复"></button></th>
<th scope="col" data-sys-todo-column="screenshots">完成截图<button type="button" class="sys-todo-table__col-resizer" data-sys-todo-col-resizer="screenshots" aria-label="调整完成截图列宽" title="拖动调整,双击恢复"></button></th>
</tr>
</thead>
<tbody>
@forelse ($items as $item)
@php
$isOverdue = ! $item->isCompleted()
&& $item->due_date !== null
&& $item->due_date->isBefore(today());
$statusClass = match ($item->status) {
\App\Models\Business\SystemizationTodo::STATUS_IN_PROGRESS => 'sys-todo-badge--progress',
\App\Models\Business\SystemizationTodo::STATUS_COMPLETED => 'sys-todo-badge--completed',
\App\Models\Business\SystemizationTodo::STATUS_DELAYED => 'sys-todo-badge--delayed',
default => '',
};
$statusClass = $item->isCompleted()
? 'sys-todo-badge--completed'
: 'sys-todo-badge--incomplete';
$todoPayload = [
'id' => $item->id,
'position' => (int) $item->item_sequence,
@ -372,7 +406,7 @@
'start_note' => $item->start_note ?? '',
'due_date' => $item->due_date?->format('Y-m-d') ?? '',
'due_note' => $item->due_note ?? '',
'status' => $item->status,
'status' => $item->status_label,
'completed_at' => $item->completed_at?->format('Y-m-d') ?? '',
'progress' => $item->progress ?? '',
'notes' => $item->notes ?? '',
@ -403,6 +437,15 @@
<span class="sys-todo-table__level" title="类型序号">{{ (int) $item->type_sequence }}</span>
<span class="sys-todo-table__group-text">{{ $item->category_display ?: '未分类' }}</span>
</span>
<span class="sys-todo-table__inline-actions">
<button type="button" class="sys-todo-table__inline-add"
data-sys-todo-inline-add
data-sys-todo-add-mode="type">+ 类型</button>
<button type="button" class="sys-todo-table__inline-add"
data-sys-todo-inline-add
data-sys-todo-add-mode="content"
data-sys-todo-add-category="{{ $item->category ?? '' }}">+ 内容</button>
</span>
</td>
@endif
@if ($sectionSpan !== 0)
@ -418,6 +461,13 @@
<span class="sys-todo-table__level" title="内容序号">{{ (int) $item->content_sequence }}</span>
<span class="sys-todo-table__group-text">{{ $sectionLabels[$loop->index] ?: '未填写内容' }}</span>
</span>
<button type="button" class="sys-todo-table__inline-add"
data-sys-todo-inline-add
data-sys-todo-add-mode="todo"
data-sys-todo-add-category="{{ $item->category ?? '' }}"
data-sys-todo-add-section="{{ $sectionLabels[$loop->index] ?? '' }}"
data-sys-todo-add-position="{{ (int) $item->content_count + 1 }}"
data-sys-todo-add-after-sequence="{{ (int) $item->content_count }}">+ 待办</button>
</td>
@endif
<td class="sys-todo-table__index-cell">
@ -442,12 +492,27 @@
<td>{{ $item->owner ?: '—' }}</td>
<td>{{ $item->assignee ?: '—' }}</td>
<td>{{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }}</td>
<td>
{{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }}
@if ($isOverdue)<br><span class="sys-todo-badge sys-todo-badge--overdue">已逾期</span>@endif
<td>{{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }}</td>
<td><span class="sys-todo-table__reminder is-{{ $item->reminder_tone }}">{{ $item->reminder_label }}</span></td>
<td><span class="sys-todo-badge {{ $statusClass }}">{{ $item->status_label }}</span></td>
<td class="sys-todo-table__progress-notes">
@if ($item->progress || $item->notes)
@if ($item->progress)
<div class="sys-todo-table__detail">
<span class="sys-todo-table__detail-label">完成情况</span>
<span>{{ $item->progress }}</span>
</div>
@endif
@if ($item->notes)
<div class="sys-todo-table__detail">
<span class="sys-todo-table__detail-label">备注</span>
<span>{{ $item->notes }}</span>
</div>
@endif
@else
—
@endif
</td>
<td><span class="sys-todo-badge {{ $statusClass }}">{{ $item->status }}</span></td>
<td class="sys-todo-table__progress">{{ $item->progress ?: '—' }}</td>
<td>
@if ($item->attachments->isNotEmpty())
<div class="sys-todo-shots">
@ -468,12 +533,21 @@
—
@endif
</td>
<td class="sys-todo-table__notes">{{ $item->notes ?: '—' }}</td>
</tr>
@empty
<tr><td colspan="12"><div class="sys-todo-empty">当前筛选条件下没有待办事项。</div></td></tr>
@endforelse
</tbody>
<tfoot>
<tr>
<td class="sys-todo-table__footer-add sys-todo-table__type">
<button type="button" class="sys-todo-table__inline-add"
data-sys-todo-inline-add
data-sys-todo-add-mode="type">+ 类型</button>
</td>
<td colspan="11" class="sys-todo-table__footer-add sys-todo-table__footer-note">也可在现有类型下方新增内容,在内容下方新增待办。</td>
</tr>
</tfoot>
</table>
</div>
<div class="sys-todo-scrollrail" tabindex="0" aria-label="表格横向滚动条" data-sys-todo-scrollrail>
@ -484,6 +558,7 @@
<form method="POST" enctype="multipart/form-data" data-sys-todo-form>
@csrf
<input type="hidden" name="_method" value="POST" data-sys-todo-method>
<input type="hidden" name="return_filters" value="{{ $todoReturnFilters }}">
<div class="sys-todo-dialog__head">
<h2 id="sysTodoDialogTitle">新增待办</h2>
<button type="button" class="sys-todo-dialog__close" data-sys-todo-close aria-label="关闭">×</button>
@ -539,7 +614,7 @@
<div class="sys-todo-date-control" data-sys-todo-date-control="start">
<input id="sysTodoStartDate" type="date" name="start_date">
<label class="sys-todo-date-pending">
<input type="checkbox" data-sys-todo-date-pending="start">
<input type="checkbox" name="start_pending" value="1" data-sys-todo-date-pending="start">
<span>待定</span>
</label>
</div>
@ -551,7 +626,7 @@
<div class="sys-todo-date-control" data-sys-todo-date-control="due">
<input id="sysTodoDueDate" type="date" name="due_date">
<label class="sys-todo-date-pending">
<input type="checkbox" data-sys-todo-date-pending="due">
<input type="checkbox" name="due_pending" value="1" data-sys-todo-date-pending="due">
<span>待定</span>
</label>
</div>
@ -559,8 +634,12 @@
<span class="sys-todo-date-note" data-sys-todo-date-note-caption="due" hidden></span>
</div>
<label class="sys-todo-field sys-todo-field--6">待办事项<textarea name="title" maxlength="5000" required data-sys-todo-auto-grow></textarea></label>
<label class="sys-todo-field sys-todo-field--6">完成情况<textarea name="progress" maxlength="10000" data-sys-todo-auto-grow></textarea></label>
<label class="sys-todo-field sys-todo-field--6">备注<textarea name="notes" maxlength="10000" data-sys-todo-auto-grow></textarea></label>
<div class="sys-todo-field sys-todo-field--12">完成情况与备注
<div class="sys-todo-combined-grid">
<label>完成情况<textarea name="progress" maxlength="10000" data-sys-todo-auto-grow></textarea></label>
<label>备注<textarea name="notes" maxlength="10000" data-sys-todo-auto-grow></textarea></label>
</div>
</div>
<div class="sys-todo-field sys-todo-field--6" data-sys-todo-upload>
<span>完成截图</span>
<div class="sys-todo-upload-zone">
@ -584,6 +663,7 @@
<form method="POST" data-sys-todo-group-form>
@csrf
@method('PATCH')
<input type="hidden" name="return_filters" value="{{ $todoReturnFilters }}">
<div class="sys-todo-group-dialog__head">
<h2 id="sysTodoGroupDialogTitle" data-sys-todo-group-title>修改类型</h2>
<button type="button" data-sys-todo-group-close aria-label="关闭">×</button>
@ -606,6 +686,7 @@
<form method="POST" data-sys-todo-delete-form>
@csrf
@method('DELETE')
<input type="hidden" name="return_filters" value="{{ $todoReturnFilters }}">
<div class="sys-todo-confirm__head">
<span class="material-icons-outlined" aria-hidden="true">warning</span>
<span id="sysTodoDeleteConfirmTitle">确认删除待办</span>
@ -700,9 +781,20 @@
var assigneeFilter = document.querySelector('[data-sys-todo-assignee-filter]');
var assigneeSummary = assigneeFilter.querySelector('[data-sys-todo-assignee-summary]');
var tableWrap = document.querySelector('[data-sys-todo-table-wrap]');
var todoTable = tableWrap.querySelector('.sys-todo-table');
var todoColumns = Array.prototype.slice.call(todoTable.querySelectorAll('[data-sys-todo-col]'));
var todoColumnResizers = Array.prototype.slice.call(
todoTable.querySelectorAll('[data-sys-todo-col-resizer]')
);
var resetColumnsButton = document.querySelector('[data-sys-todo-reset-columns]');
var scrollRail = document.querySelector('[data-sys-todo-scrollrail]');
var scrollRailSpacer = document.querySelector('[data-sys-todo-scrollrail-spacer]');
var scrollRailFrame = null;
var columnStorageKey = 'caiwu-systemization-todo-column-widths-v1';
var defaultColumnWidths = todoColumns.map(function (column) {
return Number(column.getAttribute('data-default-width') || 100);
});
var columnWidths = defaultColumnWidths.slice();
function decodePayload(value) {
var binary = atob(value);
@ -836,6 +928,96 @@
});
}
function applyColumnWidths(widths, persist) {
var total = 0;
columnWidths = todoColumns.map(function (column, index) {
var defaultWidth = defaultColumnWidths[index];
var minimum = Number(column.getAttribute('data-min-width') || 64);
var width = Math.min(720, Math.max(minimum, Number(widths[index] || defaultWidth)));
column.style.width = width + 'px';
total += width;
return width;
});
todoTable.style.width = total + 'px';
todoTable.style.minWidth = total + 'px';
todoTable.style.setProperty('--sys-todo-type-width', columnWidths[0] + 'px');
if (persist) {
try {
window.localStorage.setItem(columnStorageKey, JSON.stringify(columnWidths));
} catch (error) {
// Column resizing still works when browser storage is unavailable.
}
}
scheduleScrollRailSync();
}
function resetColumnWidth(index) {
var widths = columnWidths.slice();
widths[index] = defaultColumnWidths[index];
applyColumnWidths(widths, true);
}
try {
var storedColumnWidths = JSON.parse(window.localStorage.getItem(columnStorageKey) || 'null');
if (Array.isArray(storedColumnWidths) && storedColumnWidths.length === todoColumns.length) {
applyColumnWidths(storedColumnWidths, false);
} else {
applyColumnWidths(defaultColumnWidths, false);
}
} catch (error) {
applyColumnWidths(defaultColumnWidths, false);
}
todoColumnResizers.forEach(function (handle) {
var columnName = handle.getAttribute('data-sys-todo-col-resizer');
var columnIndex = todoColumns.findIndex(function (column) {
return column.getAttribute('data-sys-todo-col') === columnName;
});
if (columnIndex === -1) return;
var drag = null;
handle.addEventListener('pointerdown', function (event) {
event.preventDefault();
event.stopPropagation();
drag = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: columnWidths[columnIndex],
};
handle.setPointerCapture(event.pointerId);
document.body.style.userSelect = 'none';
});
handle.addEventListener('pointermove', function (event) {
if (!drag || drag.pointerId !== event.pointerId) return;
var widths = columnWidths.slice();
widths[columnIndex] = drag.startWidth + event.clientX - drag.startX;
applyColumnWidths(widths, false);
});
function finishDrag(event) {
if (!drag || drag.pointerId !== event.pointerId) return;
drag = null;
document.body.style.userSelect = '';
applyColumnWidths(columnWidths, true);
}
handle.addEventListener('pointerup', finishDrag);
handle.addEventListener('pointercancel', finishDrag);
handle.addEventListener('dblclick', function (event) {
event.preventDefault();
event.stopPropagation();
resetColumnWidth(columnIndex);
});
handle.addEventListener('keydown', function (event) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
var widths = columnWidths.slice();
widths[columnIndex] += event.key === 'ArrowLeft' ? -16 : 16;
applyColumnWidths(widths, true);
});
});
resetColumnsButton.addEventListener('click', function () {
applyColumnWidths(defaultColumnWidths, true);
});
function selectedFilesLabel() {
return Array.prototype.slice.call(screenshotInput.files || [])
.map(function (file) { return file.name; })
@ -1074,7 +1256,7 @@
applyDateControl(dueDateControl, '', '');
}
if (!currentTodoId && !(payload && payload.status)) {
setValue('status', '待办');
setValue('status', '未完成');
}
populatePositionOptions();
if (currentTodoId) {
@ -1154,11 +1336,23 @@
);
return;
}
var create = event.target.closest('[data-sys-todo-create]');
if (create) {
var inlineAdd = event.target.closest('[data-sys-todo-inline-add]');
if (inlineAdd) {
event.preventDefault();
var addMode = inlineAdd.getAttribute('data-sys-todo-add-mode') || 'todo';
openDialog({
category: create.getAttribute('data-sys-todo-default-category') || '',
section: create.getAttribute('data-sys-todo-default-section') || '',
category: addMode === 'type'
? ''
: (inlineAdd.getAttribute('data-sys-todo-add-category') || ''),
section: addMode === 'todo'
? (inlineAdd.getAttribute('data-sys-todo-add-section') || '')
: '',
insertPosition: addMode === 'todo'
? Number(inlineAdd.getAttribute('data-sys-todo-add-position') || 1)
: 1,
afterSequence: addMode === 'todo'
? Number(inlineAdd.getAttribute('data-sys-todo-add-after-sequence') || 0)
: 0,
});
return;
}

View File

@ -5,6 +5,7 @@ namespace Tests\Feature\Business;
use App\Business\SystemizationTodoWorkbookImporter;
use App\Models\Business\SystemizationTodo;
use App\Models\Business\SystemizationTodoAttachment;
use Carbon\Carbon;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
@ -33,9 +34,15 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSeeText('开始时间')
->assertSeeText('完成时间')
->assertSeeText('待定')
->assertSee('data-sys-todo-create', false)
->assertSeeText('完成情况 / 备注')
->assertSeeText('提醒')
->assertSee('data-sys-todo-inline-add', false)
->assertSee('data-sys-todo-col-resizer', false)
->assertSee('data-sys-todo-reset-columns', false)
->assertSee('data-sys-todo-date-pending="start"', false)
->assertSee('data-sys-todo-date-pending="due"', false)
->assertSee('name="start_pending"', false)
->assertSee('name="due_pending"', false)
->assertSee('data-sys-todo-owner-filter', false)
->assertSee('data-sys-todo-assignee-filter', false)
->assertSee('name="owner[]"', false)
@ -55,7 +62,7 @@ class SystemizationTodosTest extends FeatureTestCase
->assertDontSee('sys-todo-table__clamp', false)
->assertSeeText('+ 新增类型')
->assertSeeText('+ 新增内容')
->assertSeeText('点击序号旁的+可在该条之后插入')
->assertSeeText('在类型、内容下方的+直接新增')
->assertSee('data-sys-todo-choose-files', false)
->assertSee('data-sys-todo-open-delete', false)
->assertSee('id="sysTodoDeleteConfirm"', false)
@ -65,9 +72,11 @@ class SystemizationTodosTest extends FeatureTestCase
->assertDontSee('<th scope="col">操作</th>', false)
->assertDontSeeText('下一页')
->assertSee('id="sysTodoImageViewer"', false);
$this->assertSame(0, substr_count((string) $pageResponse->getContent(), 'data-sys-todo-create'));
$pageData = $pageResponse->viewData('content');
$this->assertSame(
1,
substr_count((string) $pageResponse->getContent(), 'data-sys-todo-create>'),
[SystemizationTodo::STATUS_INCOMPLETE, SystemizationTodo::STATUS_COMPLETED],
$pageData['todoStatuses'],
);
$storeResponse = $this->post(route('business.systemization-todos.store'), [
@ -78,7 +87,7 @@ class SystemizationTodosTest extends FeatureTestCase
'assignee' => '栎阳',
'start_date' => '2026-10-25',
'due_date' => '2026-10-28',
'status' => SystemizationTodo::STATUS_IN_PROGRESS,
'status' => SystemizationTodo::STATUS_INCOMPLETE,
'progress' => '已拉取账单,待核对订单',
'notes' => '周会跟进',
'screenshots' => [
@ -94,7 +103,7 @@ class SystemizationTodosTest extends FeatureTestCase
$this->assertSame((string) $todo->id, $redirectQuery['highlight'] ?? null);
$this->assertSame('收入', $todo->category);
$this->assertSame('晨曦、人参', $todo->owner);
$this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status);
$this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $todo->status);
$this->assertSame(2, $todo->attachments()->count());
$this->get(route('business.pages.show', [
'section' => 'other',
@ -372,10 +381,10 @@ class SystemizationTodosTest extends FeatureTestCase
'owner' => '晨曦',
'assignee' => '栎阳',
'start_date' => null,
'start_note' => '待定',
'start_pending' => 1,
'due_date' => null,
'due_note' => '待定',
'status' => SystemizationTodo::STATUS_PENDING,
'due_pending' => 1,
'status' => SystemizationTodo::STATUS_INCOMPLETE,
'progress' => '',
'notes' => '',
])->assertRedirect();
@ -393,10 +402,10 @@ class SystemizationTodosTest extends FeatureTestCase
'owner' => '晨曦',
'assignee' => '栎阳',
'start_date' => null,
'start_note' => '待定',
'start_pending' => 1,
'due_date' => null,
'due_note' => '11月',
'status' => SystemizationTodo::STATUS_IN_PROGRESS,
'status' => SystemizationTodo::STATUS_INCOMPLETE,
'progress' => '等待平台确认',
'notes' => '',
])->assertRedirect();
@ -407,6 +416,92 @@ class SystemizationTodosTest extends FeatureTestCase
$this->assertSame('11月', $todo->due_note);
}
public function test_edit_redirect_keeps_current_filters_and_highlight(): void
{
$this->loginAs();
$todo = $this->createTodo('收入', '平台收入核对', '筛选保持测试', '2026-10-30', 1);
$returnFilters = base64_encode(json_encode([
'status' => SystemizationTodo::STATUS_INCOMPLETE,
'category' => '收入',
'owners' => ['晨曦', '人参'],
'assignees' => ['栎阳'],
'start_date' => '2026-10-25',
'due_date' => '2026-10-30',
'keyword' => '筛选',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$response = $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [
'category' => '收入',
'section' => '平台收入核对',
'title' => '筛选保持测试(已更新)',
'owner' => '晨曦',
'assignee' => '栎阳',
'start_date' => '2026-10-25',
'due_date' => '2026-10-30',
'status' => SystemizationTodo::STATUS_INCOMPLETE,
'return_filters' => $returnFilters,
])->assertRedirect();
$location = (string) $response->headers->get('Location');
parse_str((string) parse_url($location, PHP_URL_QUERY), $query);
$this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $query['status'] ?? null);
$this->assertSame('收入', $query['category'] ?? null);
$this->assertSame(['晨曦', '人参'], $query['owner'] ?? null);
$this->assertSame(['栎阳'], $query['assignee'] ?? null);
$this->assertSame('2026-10-25', $query['start_date'] ?? null);
$this->assertSame('2026-10-30', $query['due_date'] ?? null);
$this->assertSame('筛选', $query['keyword'] ?? null);
$this->assertSame((string) $todo->id, $query['highlight'] ?? null);
}
public function test_reminder_and_status_treat_legacy_open_states_as_incomplete(): void
{
Carbon::setTestNow('2026-10-26 12:00:00');
try {
$this->loginAs();
$this->createTodo('收入', '提醒测试', '逾期事项', '2026-10-20', 1);
$this->createTodo('收入', '提醒测试', '今天到期事项', '2026-10-26', 2);
$this->createTodo('收入', '提醒测试', '未来事项', '2026-10-30', 3);
$completed = $this->createTodo('收入', '提醒测试', '已完成事项', '2026-10-22', 4);
$completed->update([
'status' => SystemizationTodo::STATUS_COMPLETED,
'completed_at' => '2026-10-22',
]);
$legacy = $this->createTodo('收入', '提醒测试', '历史进行中事项', '2026-11-01', 5);
$legacy->update(['status' => '进行中']);
$content = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))->assertOk()->viewData('content');
$items = $content['items']->keyBy('title');
$this->assertSame('逾期 6 天', $items['逾期事项']->reminder_label);
$this->assertSame('danger', $items['逾期事项']->reminder_tone);
$this->assertSame('今天到期', $items['今天到期事项']->reminder_label);
$this->assertSame('剩余 4 天', $items['未来事项']->reminder_label);
$this->assertSame('已完成', $items['已完成事项']->reminder_label);
$this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $items['历史进行中事项']->status_label);
$incompleteTitles = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'status' => SystemizationTodo::STATUS_INCOMPLETE,
]))->assertOk()->viewData('content')['items']->pluck('title');
$this->assertContains('历史进行中事项', $incompleteTitles);
$this->assertNotContains('已完成事项', $incompleteTitles);
$completedTitles = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'status' => SystemizationTodo::STATUS_COMPLETED,
]))->assertOk()->viewData('content')['items']->pluck('title');
$this->assertSame(['已完成事项'], $completedTitles->all());
} finally {
Carbon::setTestNow();
}
}
public function test_page_filters_by_date_and_merges_adjacent_category_and_section_cells(): void
{
$this->loginAs();
@ -423,7 +518,9 @@ class SystemizationTodosTest extends FeatureTestCase
$pageResponse
->assertOk()
->assertSee('rowspan="2"', false)
->assertSee('<th scope="col">内容</th><th scope="col">待办事项序号</th><th scope="col">待办事项</th>', false)
->assertSee('data-sys-todo-column="section"', false)
->assertSee('data-sys-todo-column="sequence"', false)
->assertSee('data-sys-todo-column="title"', false)
->assertSee('sys-todo-table__type', false)
->assertSee('title="类型序号">1</span>', false)
->assertSee('title="内容序号">1</span>', false)
@ -438,7 +535,7 @@ class SystemizationTodosTest extends FeatureTestCase
$this->assertContains('平台收入核对', $pageContent['todoContentOptions']['收入']);
$this->assertArrayNotHasKey('1、收入', $pageContent['todoContentOptions']);
$this->assertSame(
['__overdue__', '待办'],
['__overdue__', SystemizationTodo::STATUS_INCOMPLETE, SystemizationTodo::STATUS_COMPLETED],
array_column($pageContent['todoStatusOptions']->all(), 'value'),
);
$pageItems = $pageContent['items'];
@ -562,7 +659,7 @@ class SystemizationTodosTest extends FeatureTestCase
->all(),
);
$this->assertSame(
[$completed->id, $inProgress->id],
[$pending->id, $completed->id, $inProgress->id],
$items
->where('type_sequence', 1)
->where('content_sequence', 1)