diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 8e4498c2..932cc9c5 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -43,6 +43,18 @@ class SystemizationTodos extends Controller 'status', 'progress_notes', ]; + private const GRID_EDITABLE_FIELDS = [ + 'category', + 'section', + 'title', + 'owner', + 'assignee', + 'start_date', + 'due_date', + 'status', + 'progress', + 'notes', + ]; private const OWNER_ALIASES = [ '人参' => '人参', '苏木' => '人参', @@ -242,6 +254,9 @@ class SystemizationTodos extends Controller 'history' => route('business.systemization-todos.history'), 'historyExport' => route('business.systemization-todos.history.export'), 'columnWidths' => route('business.systemization-todos.column-widths'), + 'gridCells' => route('business.systemization-todos.grid-cells'), + 'gridMove' => route('business.systemization-todos.grid-move', ['todo' => '__TODO__']), + 'gridInsert' => route('business.systemization-todos.grid-insert', ['todo' => '__TODO__']), ], ]; } @@ -730,6 +745,279 @@ class SystemizationTodos extends Controller return $this->backToPage('待办事项已删除。'); } + public function batchUpdateCells(Request $request): JsonResponse + { + $data = $request->validate([ + 'changes' => ['required', 'array', 'min:1', 'max:1000'], + 'changes.*.id' => ['required', 'integer'], + 'changes.*.field' => ['required', Rule::in(self::GRID_EDITABLE_FIELDS)], + 'changes.*.value' => ['present', 'nullable', 'string', 'max:10000'], + 'changes.*.original' => ['nullable', 'string', 'max:10000'], + 'changes.*.scope' => ['nullable', Rule::in(['row', 'group'])], + ]); + + $result = DB::transaction(function () use ($data): array { + $todos = SystemizationTodo::query() + ->lockForUpdate() + ->get() + ->keyBy(static fn (SystemizationTodo $todo): int => (int) $todo->id); + $affectedIds = []; + $changes = []; + foreach (array_values($data['changes']) as $change) { + $todo = $todos->get((int) $change['id']); + if (! $todo instanceof SystemizationTodo) { + return ['ok' => false, 'status' => 404, 'message' => '待办不存在。']; + } + + $field = (string) $change['field']; + $scope = (string) ($change['scope'] ?? 'row'); + if (! $this->gridFieldSupportsGroupScope($field)) { + $scope = 'row'; + } + $original = $change['original'] ?? null; + if ($original !== null && ! $this->gridOriginalStillMatches($todo, $field, (string) $original)) { + return [ + 'ok' => false, + 'status' => 409, + 'message' => '待办已被其他人修改,请刷新后重试。', + ]; + } + + $targetIds = [(int) $todo->id]; + if ($scope === 'group') { + $targetIds = $this->gridGroupIds($todos, $todo, $field); + } + foreach ($targetIds as $targetId) { + $affectedIds[$targetId] = true; + } + $changes[] = [ + 'field' => $field, + 'value' => $change['value'], + 'scope' => $scope, + 'todo' => $todo, + ]; + } + + $before = []; + foreach (array_keys($affectedIds) as $todoId) { + $before[$todoId] = $this->audit->snapshot($todos->get($todoId)); + } + + foreach ($changes as $change) { + $targets = $change['scope'] === 'group' + ? $this->gridGroupIds($todos, $change['todo'], $change['field']) + : [(int) $change['todo']->id]; + foreach ($targets as $targetId) { + $todo = $todos->get($targetId); + if (! $todo instanceof SystemizationTodo) { + continue; + } + $error = $this->applyGridCellValue($todo, $change['field'], $change['value']); + if ($error !== null) { + return ['ok' => false, 'status' => 422, 'message' => $error]; + } + } + } + + $changedTodos = []; + foreach (array_keys($affectedIds) as $todoId) { + $todo = $todos->get($todoId); + if (! $todo instanceof SystemizationTodo) { + continue; + } + $error = $this->normalizeGridTodo($todo, $todoId); + if ($error !== null) { + return ['ok' => false, 'status' => 422, 'message' => $error]; + } + if (! $todo->isDirty()) { + continue; + } + $changedTodos[$todoId] = $todo; + } + + $events = []; + foreach ($changedTodos as $todoId => $todo) { + $todo->updated_by = user_id(); + $todo->save(); + $events[] = [ + 'company_id' => (int) company_id(), + 'todo_id' => (int) $todo->id, + 'action' => 'update', + 'before' => $before[$todoId], + 'after' => $this->audit->snapshot($todo), + 'user_id' => user_id(), + 'meta' => ['source' => 'grid'], + ]; + } + $this->audit->recordMany($events); + + return [ + 'ok' => true, + 'updated_cells' => count($changes), + 'updated_rows' => count($changedTodos), + 'audit_events' => count($events), + ]; + }); + + if (($result['ok'] ?? false) !== true) { + return response()->json( + ['message' => (string) ($result['message'] ?? '保存失败。')], + (int) ($result['status'] ?? 422), + ); + } + + return response()->json($result); + } + + public function moveGridRow(Request $request, SystemizationTodo $todo): JsonResponse + { + $data = $request->validate([ + 'direction' => ['required', Rule::in(['up', 'down'])], + ]); + + $result = DB::transaction(function () use ($todo, $data): array { + $lockedTodo = SystemizationTodo::query() + ->lockForUpdate() + ->findOrFail($todo->id); + $siblings = $this->contentSiblings($lockedTodo->category, $lockedTodo->section); + $currentIndex = $siblings->search( + static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $lockedTodo->id + ); + if ($currentIndex === false) { + return ['ok' => false, 'status' => 404, 'message' => '待办不存在于当前内容中。']; + } + $targetIndex = $data['direction'] === 'up' ? $currentIndex - 1 : $currentIndex + 1; + if ($targetIndex < 0) { + return ['ok' => false, 'status' => 422, 'message' => '当前待办已经是首行。']; + } + if ($targetIndex >= $siblings->count()) { + return ['ok' => false, 'status' => 422, 'message' => '当前待办已经是末行。']; + } + + /** @var SystemizationTodo $target */ + $target = $siblings->get($targetIndex); + $beforeTodo = $this->audit->snapshot($lockedTodo); + $beforeTarget = $this->audit->snapshot($target); + $todoOrder = (int) $lockedTodo->sort_order; + $targetOrder = (int) $target->sort_order; + + if ($todoOrder === $targetOrder) { + return [ + 'ok' => false, + 'status' => 409, + 'message' => '当前内容存在重复排序位置,请先通过编辑窗口调整顺序。', + ]; + } + + $lockedTodo->sort_order = $targetOrder; + $target->sort_order = $todoOrder; + $lockedTodo->updated_by = user_id(); + $target->updated_by = user_id(); + $lockedTodo->save(); + $target->save(); + $this->audit->recordMany([ + [ + 'company_id' => (int) company_id(), + 'todo_id' => (int) $lockedTodo->id, + 'action' => 'update', + 'before' => $beforeTodo, + 'after' => $this->audit->snapshot($lockedTodo), + 'user_id' => user_id(), + 'meta' => ['source' => 'grid', 'operation' => 'move_' . $data['direction']], + ], + [ + 'company_id' => (int) company_id(), + 'todo_id' => (int) $target->id, + 'action' => 'update', + 'before' => $beforeTarget, + 'after' => $this->audit->snapshot($target), + 'user_id' => user_id(), + 'meta' => ['source' => 'grid', 'operation' => 'move_' . $data['direction']], + ], + ]); + + return ['ok' => true, 'direction' => $data['direction']]; + }); + + if (($result['ok'] ?? false) !== true) { + return response()->json( + ['message' => (string) ($result['message'] ?? '移动失败。')], + (int) ($result['status'] ?? 422), + ); + } + + return response()->json($result); + } + + public function insertGridRowAfter(SystemizationTodo $todo): JsonResponse + { + $createdId = DB::transaction(function () use ($todo): int { + $lockedTodo = SystemizationTodo::query() + ->lockForUpdate() + ->findOrFail($todo->id); + $siblings = $this->contentSiblings($lockedTodo->category, $lockedTodo->section); + $currentIndex = $siblings->search( + static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $lockedTodo->id + ); + if ($currentIndex === false) { + return 0; + } + + $next = $siblings->get($currentIndex + 1); + $insertOrder = $next instanceof SystemizationTodo + ? (int) $next->sort_order + : (int) $siblings->max('sort_order') + 1; + if ($next instanceof SystemizationTodo && $insertOrder <= (int) $lockedTodo->sort_order) { + $insertOrder = (int) $lockedTodo->sort_order + 1; + } + SystemizationTodo::query() + ->where('sort_order', '>=', $insertOrder) + ->increment('sort_order'); + + $created = SystemizationTodo::query()->create([ + 'company_id' => (int) company_id(), + 'category' => $lockedTodo->category, + 'section' => $lockedTodo->section, + 'title' => '新待办', + 'owner' => '待定', + 'assignee' => null, + 'start_date' => null, + 'start_note' => null, + 'due_date' => null, + 'due_note' => null, + 'status' => SystemizationTodo::STATUS_INCOMPLETE, + 'completed_at' => null, + 'progress' => null, + 'notes' => null, + 'sort_order' => $insertOrder, + 'created_by' => user_id(), + 'updated_by' => user_id(), + ]); + $this->audit->record( + 'create', + (int) company_id(), + (int) $created->id, + null, + $this->audit->snapshot($created), + user_id(), + null, + ['source' => 'grid', 'operation' => 'insert_after'], + ); + + return (int) $created->id; + }); + + if ($createdId <= 0) { + return response()->json(['message' => '无法在当前内容中插入新行。'], 422); + } + + return response()->json([ + 'ok' => true, + 'id' => $createdId, + 'message' => '已插入空白待办行。', + ]); + } + public function history(): JsonResponse { $from = $this->historyStartAt(); @@ -1068,6 +1356,161 @@ class SystemizationTodos extends Controller : ''; } + private function gridFieldSupportsGroupScope(string $field): bool + { + return in_array($field, ['category', 'section'], true); + } + + private function gridOriginalStillMatches( + SystemizationTodo $todo, + string $field, + string $original, + ): bool { + $current = match ($field) { + 'category', 'section', 'title' => $this->hierarchyLabel((string) ($todo->{$field} ?? '')), + 'owner' => implode('、', $this->ownerNames($todo->owner)), + 'start_date' => $todo->start_date?->format('Y-m-d') ?? (string) ($todo->start_note ?? ''), + 'due_date' => $todo->due_date?->format('Y-m-d') ?? (string) ($todo->due_note ?? ''), + 'status' => $todo->statusLabel(), + default => (string) ($todo->{$field} ?? ''), + }; + $expected = in_array($field, ['category', 'section', 'title'], true) + ? $this->hierarchyLabel($original) + : $original; + + return $current === $expected; + } + + /** + * @param Collection $todos + * @return array + */ + private function gridGroupIds( + Collection $todos, + SystemizationTodo $todo, + string $field, + ): array { + $categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)); + $sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)); + + return $todos + ->filter(function (SystemizationTodo $candidate) use ( + $field, + $categoryKey, + $sectionKey, + ): bool { + $sameCategory = $this->hierarchyGroupKey( + $this->hierarchyLabel($candidate->category) + ) === $categoryKey; + + return $sameCategory + && ($field === 'category' + || $this->hierarchyGroupKey( + $this->hierarchyLabel($candidate->section) + ) === $sectionKey); + }) + ->keys() + ->map(static fn (mixed $id): int => (int) $id) + ->values() + ->all(); + } + + private function applyGridCellValue( + SystemizationTodo $todo, + string $field, + ?string $value, + ): ?string { + $value = str_replace(["\r\n", "\r"], "\n", (string) ($value ?? '')); + if (in_array($field, ['category', 'section', 'title', 'owner', 'assignee'], true)) { + $value = trim($value); + } + + if (in_array($field, ['start_date', 'due_date'], true)) { + $dateField = $field; + $noteField = $field === 'start_date' ? 'start_note' : 'due_note'; + $value = trim($value); + if ($value === '') { + $todo->{$dateField} = null; + $todo->{$noteField} = null; + return null; + } + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if ($date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value) { + $todo->{$dateField} = $value; + $todo->{$noteField} = null; + return null; + } + if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $value) === 1) { + return $field === 'start_date' ? '开始时间日期格式无效。' : '完成时间日期格式无效。'; + } + $todo->{$dateField} = null; + $todo->{$noteField} = $value; + return null; + } + + if ($field === 'status') { + $status = trim($value); + if (in_array($status, SystemizationTodo::LEGACY_INCOMPLETE_STATUSES, true)) { + $status = SystemizationTodo::STATUS_INCOMPLETE; + } + if (! in_array($status, SystemizationTodo::STATUSES, true)) { + return '状态只能填写“未完成”或“已完成”。'; + } + $todo->status = $status; + return null; + } + + $todo->{$field} = $value === '' ? null : $value; + + return null; + } + + private function normalizeGridTodo(SystemizationTodo $todo, int $todoId): ?string + { + $title = trim((string) ($todo->title ?? '')); + if ($title === '') { + return '待办事项不能为空。'; + } + $limits = [ + 'category' => 100, + 'section' => 255, + 'title' => 5000, + 'owner' => 255, + 'assignee' => 255, + 'progress' => 10000, + 'notes' => 10000, + 'start_note' => 50, + 'due_note' => 50, + ]; + foreach ($limits as $field => $limit) { + if (mb_strlen((string) ($todo->{$field} ?? '')) > $limit) { + return '第 ' . $todoId . ' 条待办的字段内容过长。'; + } + } + + $todo->title = $title; + $todo->owner = implode('、', $this->ownerNames($todo->owner)); + $assignees = $this->personNames($todo->assignee); + $todo->assignee = $assignees === [] ? null : implode('、', $assignees); + + $startDate = $todo->start_date?->format('Y-m-d'); + $dueDate = $todo->due_date?->format('Y-m-d'); + if ($startDate !== null && $dueDate !== null && $dueDate < $startDate) { + return '第 ' . $todoId . ' 条待办的完成时间不能早于开始时间。'; + } + + if ($todo->statusLabel() === SystemizationTodo::STATUS_COMPLETED) { + $todo->status = SystemizationTodo::STATUS_COMPLETED; + $todo->completed_at = $todo->completed_at?->format('Y-m-d') + ?: ($dueDate ?: now()->toDateString()); + } else { + $todo->status = SystemizationTodo::STATUS_INCOMPLETE; + $todo->completed_at = null; + } + + return null; + } + private function validated(Request $request): array { return $request->validate([ diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index 05039f9b..51ba939c 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -66,6 +66,21 @@ .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-grid-tools { display:flex; flex-wrap:wrap; align-items:center; gap:.55rem .8rem; margin:0 0 .6rem; padding:.55rem .7rem; border:1px solid #e6dfd2; border-radius:.55rem; background:#fcfaf5; color:#595959; font-size:.72rem; line-height:1.55; } + .sys-todo-grid-tools__copy { min-width:20rem; flex:1 1 34rem; } + .sys-todo-grid-state { display:inline-flex; align-items:center; gap:.3rem; min-height:1.7rem; padding:0 .55rem; border:1px solid #bad0ac; border-radius:999px; background:#f2f8ed; color:#53793e; white-space:nowrap; } + .sys-todo-grid-state.is-saving { border-color:#e5c98f; background:#fff8e8; color:#935f07; } + .sys-todo-grid-state.is-error { border-color:#e7c3c3; background:#fff3f3; color:#b80000; } + .sys-todo-grid-retry { min-height:1.9rem; padding:0 .65rem; border:1px solid #e7c3c3; border-radius:.4rem; background:#fff; color:#b80000; font:inherit; font-size:.72rem; cursor:pointer; } + .sys-todo-grid-retry[hidden] { display:none; } + .sys-todo-grid-value { display:block; min-width:0; min-height:1.65rem; padding:.18rem .25rem; border:1px solid transparent; border-radius:.3rem; cursor:text; outline:none; } + .sys-todo-grid-value:hover { border-color:#ded4c3; background:#fffdf8; } + .sys-todo-grid-value:focus-visible, .sys-todo-grid-value.is-selected { border-color:var(--sys-gold); background:var(--sys-gold-soft); box-shadow:0 0 0 2px rgba(133,102,50,.16); } + .sys-todo-grid-value.is-editing { padding:0; border-color:var(--sys-gold); background:#fff; box-shadow:0 0 0 3px rgba(133,102,50,.18); } + .sys-todo-grid-value.is-dirty { background:#fff8e8; } + .sys-todo-grid-editor { display:block; box-sizing:border-box; width:100%; min-height:1.9rem; padding:.3rem .4rem; border:0; border-radius:.28rem; background:#fff; color:#171717; font:inherit; font-size:.78rem; line-height:1.5; outline:none; resize:vertical; } + textarea.sys-todo-grid-editor { min-height:4rem; } + .sys-todo-grid-value .sys-todo-table__people-list { pointer-events:none; } .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; } @@ -73,6 +88,14 @@ .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__row-actions { display:inline-flex; flex-wrap:wrap; justify-content:center; gap:.18rem; margin-top:.35rem; } + .sys-todo-table__row-action { display:inline-flex; align-items:center; justify-content:center; width:1.35rem; height:1.35rem; padding:0; border:1px solid #d8c9aa; border-radius:.32rem; background:#fff; color:#6d5630; cursor:pointer; } + .sys-todo-table__row-action:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); color:#6a5129; } + .sys-todo-table__row-action:focus-visible { outline:2px solid var(--sys-gold); outline-offset:1px; } + .sys-todo-table__row-action:disabled { border-color:#e6dfd2; background:#f8f6f1; color:#a5a09a; cursor:not-allowed; } + .sys-todo-table__row-action--danger { border-color:#e7c3c3; color:#b80000; } + .sys-todo-table__row-action--danger:hover { border-color:#d89a9a; background:#fff3f3; color:#b80000; } + .sys-todo-table__row-action .material-icons-outlined { font-size:.9rem; line-height:1; } .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; } @@ -508,12 +531,20 @@ @endphp
- 共 {{ number_format($items->count()) }} 条,已全部显示。在类型、内容下方的+直接新增;拖动表头分隔线可调整列宽,双击分隔线可恢复该列。 + 共 {{ number_format($items->count()) }} 条,已全部显示。拖动表头分隔线可调整列宽,双击分隔线可恢复该列。
+
+ 单击单元格直接编辑;从原有单元格拖动可选中区域,再将 Excel 内容粘贴进来。分类、内容和排序会在保存后重新汇总。 + 已保存 + +
+
- +
@@ -579,7 +610,10 @@ $categorySpan = $groupRowspans['category'][$loop->index] ?? 1; $sectionSpan = $groupRowspans['section'][$loop->index] ?? 1; @endphp - + @if ($categorySpan !== 0) @php($assigneeNames = $personNames($item->assignee)) + @php($startGridValue = $item->start_date?->format('Y-m-d') ?: (string) ($item->start_note ?? '')) + + @php($dueGridValue = $item->due_date?->format('Y-m-d') ?: (string) ($item->due_note ?? '')) + - - - + @@ -717,7 +865,7 @@ data-sys-todo-inline-add data-sys-todo-add-mode="type">+ 类型 - +
1) rowspan="{{ $categorySpan }}" @endif @@ -591,7 +625,15 @@ data-sys-todo-group-count="{{ (int) $item->type_count }}"> {{ (int) $item->type_sequence }} - {{ $item->category_display ?: '未分类' }} + {{ $item->category_display ?: '未分类' }} + + + + +
- {{ $titleLabels[$loop->index] }} + {{ $titleLabels[$loop->index] }} @if ($item->attachments->isNotEmpty())
- @if ($ownerNames === []) - — - @else - + + @if ($ownerNames === []) + — + @else + @foreach ($ownerNames as $ownerName) {{ $ownerName }} @endforeach - - @endif + + @endif + - @if ($assigneeNames === []) - — - @else - + + @if ($assigneeNames === []) + — + @else + @foreach ($assigneeNames as $assigneeName) {{ $assigneeName }} @endforeach - - @endif + + @endif + + + {{ $startGridValue ?: '—' }} + + {{ $dueGridValue ?: '—' }} {{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }}{{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }} {{ $item->reminder_label }}{{ $item->status_label }} + + {{ $item->status_label }} + +
- @if ($displayProgress !== '' || $displayNotes !== '') - @if ($displayProgress !== '') -
- 完成情况 - {{ $displayProgress }} -
- @endif - @if ($displayNotes !== '') -
- 备注 - {{ $displayNotes }} -
- @endif - @else - — - @endif +
+ 完成情况 + {{ $displayProgress ?: '—' }} +
+
+ 备注 + {{ $displayNotes ?: '—' }} +
@@ -2230,4 +2378,769 @@ }); })(); + + diff --git a/routes/admin.php b/routes/admin.php index 14c70fe3..46167ce5 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -795,6 +795,14 @@ Route::prefix('workspace/other/systemization-todos') ->name('column-widths'); Route::post('/', [SystemizationTodos::class, 'store'])->name('store'); Route::get('history', [SystemizationTodos::class, 'history'])->name('history'); + Route::patch('grid/cells', [SystemizationTodos::class, 'batchUpdateCells']) + ->name('grid-cells'); + Route::patch('grid/{todo}/move', [SystemizationTodos::class, 'moveGridRow']) + ->whereNumber('todo') + ->name('grid-move'); + Route::post('grid/{todo}/insert-after', [SystemizationTodos::class, 'insertGridRowAfter']) + ->whereNumber('todo') + ->name('grid-insert'); Route::patch('{todo}/category', [SystemizationTodos::class, 'updateCategory']) ->whereNumber('todo') ->name('category'); diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index ad46b479..b235b0cb 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -91,6 +91,9 @@ class SystemizationTodosTest extends FeatureTestCase ->assertSee('id="sysTodoDeleteConfirm"', false) ->assertSee('data-sys-todo-table-wrap', false) ->assertSee('data-sys-todo-scrollrail', false) + ->assertSee('data-sys-todo-grid-save-url', false) + ->assertSeeText('单击单元格直接编辑') + ->assertSeeText('将 Excel 内容粘贴进来') ->assertSee('data-sys-todo-image-prev', false) ->assertSee('data-sys-todo-image-next', false) ->assertDontSee('playlist_add', false) @@ -141,6 +144,13 @@ class SystemizationTodosTest extends FeatureTestCase ->assertOk() ->assertSee('data-sys-todo-row-edit', false) ->assertSee('data-sys-todo-image-gallery', false) + ->assertSee('data-sys-todo-grid-cell', false) + ->assertSee('data-sys-todo-grid-field="title"', false) + ->assertSee('data-sys-todo-grid-field="progress"', false) + ->assertSee('data-sys-todo-grid-insert', false) + ->assertSee('data-sys-todo-grid-move="up"', false) + ->assertSee('data-sys-todo-grid-move="down"', false) + ->assertSee('data-sys-todo-grid-delete', false) ->assertSeeText('截图 2'); $firstAttachment = $todo->attachments()->firstOrFail(); $existingAttachment = $todo->attachments()->reorder()->orderByDesc('id')->firstOrFail(); @@ -327,18 +337,20 @@ class SystemizationTodosTest extends FeatureTestCase $html, ); $this->assertMatchesRegularExpression( - '/\s*\s*' + '/\s*]*>\s*' + . '\s*' . '晨曦<\/span>\s*' . '人参<\/span>\s*' - . '<\/span>\s*<\/td>/s', + . '<\/span>\s*<\/span>\s*<\/td>/s', $html, ); $this->assertMatchesRegularExpression( - '/\s*\s*' + '/\s*]*>\s*' + . '\s*' . '人参<\/span>\s*' . '秋香<\/span>\s*' . '常仪<\/span>\s*' - . '<\/span>\s*<\/td>/s', + . '<\/span>\s*<\/span>\s*<\/td>/s', $html, ); } @@ -560,7 +572,7 @@ class SystemizationTodosTest extends FeatureTestCase 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk() - ->assertSee('title="在第 3 条后新增一条"', false) + ->assertSee('title="在第 3 条后插入空白行"', false) ->assertSee('data-sys-todo-insert-position="4"', false); $this->assertMatchesRegularExpression( '/id="sys-todo-row-' . $items[3]->id . '".*?data-sys-todo-insert-position="4"/s', @@ -709,6 +721,226 @@ class SystemizationTodosTest extends FeatureTestCase $this->assertSame('成本核对', $cost->fresh()->section); } + public function test_grid_cell_batch_update_supports_row_fields_and_group_scope(): void + { + $this->loginAs(); + + $first = $this->createTodo('收入', '平台收入核对', '核对收入 A', '2026-11-30', 1); + $second = $this->createTodo('收入', '平台收入核对', '核对收入 B', '2026-11-30', 2); + $other = $this->createTodo('成本', '成本核对', '核对成本', '2026-11-30', 3); + + $this->patchJson(route('business.systemization-todos.grid-cells'), [ + 'changes' => [ + [ + 'id' => $first->id, + 'field' => 'category', + 'value' => '业务收入', + 'original' => '收入', + 'scope' => 'group', + ], + [ + 'id' => $first->id, + 'field' => 'title', + 'value' => '核对收入 A(已更新)', + 'original' => '核对收入 A', + 'scope' => 'row', + ], + [ + 'id' => $first->id, + 'field' => 'owner', + 'value' => '晨曦、人参', + 'original' => '晨曦', + 'scope' => 'row', + ], + [ + 'id' => $first->id, + 'field' => 'start_date', + 'value' => '2026-10-28', + 'original' => '2026-10-25', + 'scope' => 'row', + ], + [ + 'id' => $first->id, + 'field' => 'due_date', + 'value' => '2026-11-15', + 'original' => '2026-11-30', + 'scope' => 'row', + ], + [ + 'id' => $first->id, + 'field' => 'status', + 'value' => SystemizationTodo::STATUS_COMPLETED, + 'original' => SystemizationTodo::STATUS_INCOMPLETE, + 'scope' => 'row', + ], + [ + 'id' => $first->id, + 'field' => 'progress', + 'value' => '已完成第一轮核对', + 'original' => '', + 'scope' => 'row', + ], + [ + 'id' => $first->id, + 'field' => 'notes', + 'value' => '周会复核', + 'original' => '', + 'scope' => 'row', + ], + ], + ])->assertOk() + ->assertJson([ + 'ok' => true, + 'updated_cells' => 8, + 'updated_rows' => 2, + ]); + + $first->refresh(); + $second->refresh(); + $this->assertSame('业务收入', $first->category); + $this->assertSame('业务收入', $second->category); + $this->assertSame('成本', $other->fresh()->category); + $this->assertSame('核对收入 A(已更新)', $first->title); + $this->assertSame('晨曦、人参', $first->owner); + $this->assertSame('2026-10-28', $first->start_date?->format('Y-m-d')); + $this->assertSame('2026-11-15', $first->due_date?->format('Y-m-d')); + $this->assertSame(SystemizationTodo::STATUS_COMPLETED, $first->status); + $this->assertSame('2026-11-15', $first->completed_at?->format('Y-m-d')); + $this->assertSame('已完成第一轮核对', $first->progress); + $this->assertSame('周会复核', $first->notes); + $this->assertDatabaseHas('business_systemization_todo_audit_logs', [ + 'company_id' => (int) company_id(), + 'todo_id' => $first->id, + 'action' => 'update', + ]); + } + + public function test_grid_paste_can_change_one_category_without_splitting_the_whole_group_by_default(): void + { + $this->loginAs(); + + $first = $this->createTodo('收入', '平台收入核对', '收入事项 A', '2026-11-30', 1); + $second = $this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-11-30', 2); + + $this->patchJson(route('business.systemization-todos.grid-cells'), [ + 'changes' => [[ + 'id' => $second->id, + 'field' => 'category', + 'value' => '其他收入', + 'original' => '收入', + 'scope' => 'row', + ]], + ])->assertOk() + ->assertJsonPath('updated_rows', 1); + + $this->assertSame('收入', $first->fresh()->category); + $this->assertSame('其他收入', $second->fresh()->category); + } + + public function test_grid_batch_update_rejects_invalid_dates_and_conflicts_atomically(): void + { + $this->loginAs(); + + $todo = $this->createTodo('收入', '平台收入核对', '日期校验', '2026-11-30', 1); + $this->patchJson(route('business.systemization-todos.grid-cells'), [ + 'changes' => [ + [ + 'id' => $todo->id, + 'field' => 'start_date', + 'value' => '2026-12-01', + 'original' => '2026-10-25', + 'scope' => 'row', + ], + [ + 'id' => $todo->id, + 'field' => 'due_date', + 'value' => '2026-11-01', + 'original' => '2026-11-30', + 'scope' => 'row', + ], + ], + ])->assertStatus(422); + + $todo->refresh(); + $this->assertSame('2026-10-25', $todo->start_date?->format('Y-m-d')); + $this->assertSame('2026-11-30', $todo->due_date?->format('Y-m-d')); + + $this->patchJson(route('business.systemization-todos.grid-cells'), [ + 'changes' => [[ + 'id' => $todo->id, + 'field' => 'title', + 'value' => '不应覆盖', + 'original' => '已被他人修改的标题', + 'scope' => 'row', + ]], + ])->assertStatus(409); + $this->assertSame('日期校验', $todo->fresh()->title); + } + + public function test_grid_row_can_move_up_and_down_within_its_content_group(): void + { + $this->loginAs(); + + $first = $this->createTodo('收入', '顺序测试', '第一行', '2026-11-30', 1); + $second = $this->createTodo('收入', '顺序测试', '第二行', '2026-11-30', 2); + $third = $this->createTodo('收入', '顺序测试', '第三行', '2026-11-30', 3); + + $this->patchJson(route('business.systemization-todos.grid-move', ['todo' => $second->id]), [ + 'direction' => 'up', + ])->assertOk(); + + $this->assertLessThan($first->fresh()->sort_order, $second->fresh()->sort_order); + $this->assertLessThan($third->fresh()->sort_order, $first->fresh()->sort_order); + + $titles = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ]))->viewData('content')['items']->pluck('title')->all(); + $this->assertSame(['第二行', '第一行', '第三行'], $titles); + + $this->patchJson(route('business.systemization-todos.grid-move', ['todo' => $second->id]), [ + 'direction' => 'down', + ])->assertOk(); + $this->assertSame( + ['第一行', '第二行', '第三行'], + $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ]))->viewData('content')['items']->pluck('title')->all(), + ); + } + + public function test_grid_insert_creates_a_blank_row_after_the_selected_todo(): void + { + $this->loginAs(); + + $first = $this->createTodo('收入', '插入测试', '第一行', '2026-11-30', 1); + $second = $this->createTodo('收入', '插入测试', '第二行', '2026-11-30', 2); + + $response = $this->postJson(route('business.systemization-todos.grid-insert', [ + 'todo' => $first->id, + ]))->assertOk() + ->assertJsonPath('ok', true); + + $createdId = (int) $response->json('id'); + $created = SystemizationTodo::query()->findOrFail($createdId); + $this->assertSame('新待办', $created->title); + $this->assertSame('收入', $created->category); + $this->assertSame('插入测试', $created->section); + $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $created->status); + $this->assertLessThan($second->fresh()->sort_order, $first->fresh()->sort_order); + $this->assertLessThan($second->fresh()->sort_order, $created->sort_order); + $this->assertGreaterThan($first->fresh()->sort_order, $created->sort_order); + + $this->assertSame( + ['第一行', '新待办', '第二行'], + $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ]))->viewData('content')['items']->pluck('title')->all(), + ); + } + public function test_excel_import_preserves_hierarchy_and_does_not_overwrite_existing_rows(): void { $path = sys_get_temp_dir() . '/systemization-todos-' . uniqid() . '.xlsx';