'已逾期', 'today' => '今天到期', 'upcoming' => '剩余天数', 'pending' => '时间待定', 'none' => '已完成', ]; private const OWNER_OPTIONS = ['人参', '常仪', '晨曦', '待定']; private const ASSIGNEE_OPTIONS = [ '人参', '苏木', '李斯', '周生辰', '常仪', '玉兔', '山时', '杳杳', '林冲', '惠兰', '晨曦', '栎阳', '秋香', '待定', ]; private const COLUMN_WIDTHS_SETTING = 'business.systemization_todos.column_widths'; private const COLUMN_WIDTH_KEYS = [ 'type', 'section', 'sequence', 'title', 'owner', 'assignee', 'start_date', 'due_date', 'reminder', 'status', 'progress_notes', ]; private const GRID_EDITABLE_FIELDS = [ 'category', 'section', 'title', 'owner', 'assignee', 'start_date', 'due_date', 'status', 'progress', ]; private const FILTER_QUERY_KEYS = [ 'status' => 'status', 'reminder' => 'reminder', 'category' => 'category', 'owners' => 'owner', 'assignees' => 'assignee', 'start_date' => 'start_date', 'due_date' => 'due_date', 'keyword' => 'keyword', ]; private const OWNER_ALIASES = [ '人参' => '人参', '苏木' => '人参', '李斯' => '人参', '周生辰' => '人参', '晨曦' => '晨曦', '栎阳' => '晨曦', '秋香' => '晨曦', '常仪' => '常仪', '玉兔' => '常仪', '山时' => '常仪', '林冲' => '常仪', '杳杳' => '常仪', '待定' => '待定', ]; public function __construct( private readonly SystemizationTodoAuditLogger $audit, ) { } public function page(): array { $filters = $this->filters(); $sequenceMap = $this->hierarchySequenceMap(); $items = $this->decorateGridItems($this->filteredItems($filters), $sequenceMap); $positionGroups = $this->positionGroups($sequenceMap); $summary = $this->todoSummary(); $categories = SystemizationTodo::query() ->whereNotNull('category') ->where('category', '<>', '') ->distinct() ->orderBy('category') ->pluck('category'); $statusOptions = collect(); foreach (SystemizationTodo::STATUSES as $status) { $statusOptions->push([ 'value' => $status, 'label' => $status, ]); } $todoAssignees = $this->peopleOptions('assignee'); return [ 'view' => 'business.pages.systemization-todos', 'title' => '系统化待办', 'items' => $items, 'todoPositionGroups' => $positionGroups, 'todoFilters' => $filters, 'todoCategories' => $categories, 'todoContentOptions' => $this->contentOptions(), 'todoOwners' => collect(self::OWNER_OPTIONS), 'todoAssignees' => $todoAssignees, 'todoAssigneeOptions' => collect(self::ASSIGNEE_OPTIONS), 'todoAssigneeOwners' => $this->assigneeOwnerMap($todoAssignees), 'todoStatuses' => SystemizationTodo::STATUSES, 'todoStatusOptions' => $statusOptions, 'todoReminderOptions' => collect(self::REMINDER_OPTIONS) ->map(static fn (string $label, string $value): array => [ 'value' => $value, 'label' => $label, ]) ->values(), 'todoReturnFilters' => base64_encode(json_encode( $filters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, )), 'todoColumnWidths' => $this->columnWidths(), 'todoSummary' => $summary, 'todoRoutes' => [ 'store' => route('business.systemization-todos.store'), 'update' => route('business.systemization-todos.update', ['todo' => '__TODO__']), 'category' => route('business.systemization-todos.category', ['todo' => '__TODO__']), 'section' => route('business.systemization-todos.section', ['todo' => '__TODO__']), 'destroy' => route('business.systemization-todos.destroy', ['todo' => '__TODO__']), 'attachment' => route('business.systemization-todos.attachments.show', [ 'todo' => '__TODO__', 'attachment' => '__ATTACHMENT__', ]), 'import' => route('business.systemization-todos.import'), '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__']), 'gridCreate' => route('business.systemization-todos.grid-create'), 'gridUpload' => route('business.systemization-todos.grid-upload', ['todo' => '__TODO__']), ], ]; } /** * @param array $filters * @return Collection */ private function filteredItems(array $filters): Collection { $query = SystemizationTodo::query() ->with('attachments') ->withCount('attachments'); if ($filters['status'] === self::OVERDUE_FILTER) { $query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()); } 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['reminder'] !== '') { if ($filters['reminder'] === 'overdue') { $query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()); } elseif ($filters['reminder'] === 'today') { $query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) ->whereDate('due_date', today()); } elseif ($filters['reminder'] === 'upcoming') { $query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) ->whereDate('due_date', '>', today()); } elseif ($filters['reminder'] === 'pending') { $query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) ->whereNull('due_date'); } elseif ($filters['reminder'] === 'none') { $query->where('status', SystemizationTodo::STATUS_COMPLETED); } } if ($filters['category'] !== '') { $query->where('category', $filters['category']); } if ($filters['owners'] !== []) { $query->where(function ($builder) use ($filters): void { foreach ($filters['owners'] as $owner) { $this->applyOwnerFilter($builder, $owner); } }); } if ($filters['assignees'] !== []) { $query->where(function ($builder) use ($filters): void { foreach ($filters['assignees'] as $assignee) { $builder->orWhere( 'assignee', 'like', '%' . addcslashes($assignee, '%_\\') . '%', ); } }); } if ($filters['start_date'] !== '') { $query->whereDate('start_date', $filters['start_date']); } if ($filters['due_date'] !== '') { $query->whereDate('due_date', $filters['due_date']); } if ($filters['keyword'] !== '') { $like = '%' . addcslashes($filters['keyword'], '%_\\') . '%'; $query->where(function ($builder) use ($like): void { $builder->where('title', 'like', $like) ->orWhere('category', 'like', $like) ->orWhere('section', 'like', $like) ->orWhere('owner', 'like', $like) ->orWhere('assignee', 'like', $like) ->orWhere('progress', 'like', $like); }); } return $query ->select($query->qualifyColumn('*')) ->orderBy('sort_order') ->orderBy('id') ->get(); } /** * @param Collection $items * @param array> $sequenceMap * @return Collection */ private function decorateGridItems(Collection $items, array $sequenceMap): Collection { $items->each(function (SystemizationTodo $todo) use ($sequenceMap): void { $sequence = $sequenceMap[$todo->id] ?? [ 'type_sequence' => 0, 'content_sequence' => 0, 'item_sequence' => 0, 'order' => PHP_INT_MAX, 'category_display' => '', 'section_display' => '', 'type_count' => 0, 'content_count' => 0, ]; $todo->setAttribute('type_sequence', $sequence['type_sequence']); $todo->setAttribute('content_sequence', $sequence['content_sequence']); $todo->setAttribute('item_sequence', $sequence['item_sequence']); $todo->setAttribute('hierarchy_order', $sequence['order']); $todo->setAttribute('category_display', $sequence['category_display']); $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()); $todo->setAttribute('owner_display', implode('、', $this->ownerNames($todo->owner))); $reminder = $todo->reminder(); $todo->setAttribute('reminder_label', $reminder['label']); $todo->setAttribute('reminder_tone', $reminder['tone']); }); return $items->sortBy('hierarchy_order')->values(); } private function todoSummary(): array { $counts = SystemizationTodo::query() ->selectRaw( 'COUNT(*) AS total, ' . 'COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS completed, ' . 'COALESCE(SUM(CASE WHEN status <> ? AND due_date IS NOT NULL AND due_date < ? THEN 1 ELSE 0 END), 0) AS overdue', [ SystemizationTodo::STATUS_COMPLETED, SystemizationTodo::STATUS_COMPLETED, today()->toDateString(), ], ) ->first(); $total = (int) ($counts->total ?? 0); $completed = (int) ($counts->completed ?? 0); return [ 'total' => $total, 'incomplete' => max(0, $total - $completed), 'completed' => $completed, 'overdue' => (int) ($counts->overdue ?? 0), ]; } public function saveColumnWidths(Request $request): JsonResponse { $ruleKeys = implode(',', self::COLUMN_WIDTH_KEYS); $data = $request->validate([ 'widths' => ['required', 'array:' . $ruleKeys], 'widths.*' => ['required', 'integer', 'min:64', 'max:720'], ]); $widths = []; foreach (self::COLUMN_WIDTH_KEYS as $key) { $widths[$key] = (int) $data['widths'][$key]; } setting([ self::COLUMN_WIDTHS_SETTING => json_encode( $widths, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, ), ])->save(); return response()->json(['ok' => true, 'widths' => $widths]); } private function columnWidths(): ?array { $encoded = setting(self::COLUMN_WIDTHS_SETTING); $widths = is_string($encoded) ? json_decode($encoded, true) : $encoded; if (! is_array($widths)) { return null; } $normalized = []; foreach (self::COLUMN_WIDTH_KEYS as $key) { $width = $widths[$key] ?? null; if (! is_int($width) && ! ctype_digit((string) $width)) { return null; } $width = (int) $width; if ($width < 64 || $width > 720) { return null; } $normalized[$key] = $width; } return $normalized; } private function hierarchySequenceMap(): array { $todos = SystemizationTodo::query() ->orderBy('sort_order') ->orderBy('id') ->get(['id', 'category', 'section', 'sort_order']); $rows = $todos->map(function (SystemizationTodo $todo): array { $category = $this->hierarchyLabel($todo->category); $section = $this->hierarchyLabel($todo->section); return [ 'category_key' => $this->hierarchyGroupKey($category), 'category_label' => $category, 'section_key' => $this->hierarchyGroupKey($section), 'section_label' => $section, 'sort_order' => (int) $todo->sort_order, 'id' => (int) $todo->id, ]; })->all(); $categoryOrder = []; $categoryLabels = []; $sectionOrder = []; $sectionLabels = []; $groupedRows = []; foreach ($rows as $row) { $categoryKey = $row['category_key']; $sectionKey = $row['section_key']; $categoryOrder[$categoryKey] = min( $categoryOrder[$categoryKey] ?? PHP_INT_MAX, $row['sort_order'], ); $categoryLabels[$categoryKey] ??= $row['category_label']; $sectionOrder[$categoryKey][$sectionKey] = min( $sectionOrder[$categoryKey][$sectionKey] ?? PHP_INT_MAX, $row['sort_order'], ); $sectionLabels[$categoryKey][$sectionKey] ??= $row['section_label']; $groupedRows[$categoryKey][$sectionKey][] = $row; } $categoryKeys = array_keys($categoryOrder); usort($categoryKeys, function (string $left, string $right) use ( $categoryOrder, $categoryLabels, ): int { return $this->compareHierarchyGroups( $left, $right, $categoryOrder, $categoryLabels, ); }); $sequenceMap = []; $hierarchyOrder = 0; foreach ($categoryKeys as $categoryIndex => $categoryKey) { $typeCount = array_sum(array_map( static fn (array $rows): int => count($rows), $groupedRows[$categoryKey], )); $contentKeys = array_keys($sectionOrder[$categoryKey]); usort($contentKeys, function (string $left, string $right) use ( $categoryKey, $sectionOrder, $sectionLabels, ): int { return $this->compareHierarchyGroups( $left, $right, $sectionOrder[$categoryKey], $sectionLabels[$categoryKey], ); }); foreach ($contentKeys as $contentIndex => $contentKey) { $contentRows = $groupedRows[$categoryKey][$contentKey]; usort($contentRows, static fn (array $left, array $right): int => [$left['sort_order'], $left['id']] <=> [$right['sort_order'], $right['id']] ); foreach ($contentRows as $itemIndex => $row) { $sequenceMap[$row['id']] = [ 'type_sequence' => $categoryIndex + 1, 'content_sequence' => $contentIndex + 1, 'item_sequence' => $itemIndex + 1, 'order' => $hierarchyOrder++, 'category_display' => $row['category_label'], 'section_display' => $row['section_label'], 'type_count' => $typeCount, 'content_count' => count($contentRows), ]; } } } return $sequenceMap; } private function positionGroups(array $sequenceMap): array { $groups = []; $todos = SystemizationTodo::query() ->orderBy('sort_order') ->orderBy('id') ->get(['id', 'category', 'section', 'sort_order']); foreach ($todos as $todo) { $sequence = $sequenceMap[$todo->id] ?? null; if ($sequence === null) { continue; } $key = $sequence['type_sequence'] . ':' . $sequence['content_sequence']; $groups[$key] ??= [ 'category' => (string) ($todo->category ?? ''), 'section' => $sequence['section_display'], 'count' => 0, ]; $groups[$key]['count']++; } return array_values($groups); } private function compareHierarchyGroups( string $left, string $right, array $sortOrders, array $labels, ): int { $comparison = ($sortOrders[$left] ?? PHP_INT_MAX) <=> ($sortOrders[$right] ?? PHP_INT_MAX); return $comparison !== 0 ? $comparison : strnatcasecmp($labels[$left] ?? '', $labels[$right] ?? ''); } private function hierarchyLabel(?string $value): string { return trim((string) preg_replace( '/(^|\R)\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;))]\s*/u', '$1', (string) $value, )); } private function hierarchyGroupKey(string $label): string { $normalized = preg_replace( '/[\s\p{Z}\x{200B}\x{FEFF},,、。..;;::!!??()()【】\[\]“”"\'‘’「」『』《》—\-_\/]+/u', '', $label, ); $normalized = (string) $normalized; return $normalized === '' ? "\0" : mb_strtolower($normalized); } public function store(Request $request): RedirectResponse { $data = $this->validated($request); $this->normalizePendingDates($request, $data); $data['progress'] = SystemizationTodo::mergeNotesIntoProgress( $data['progress'] ?? null, $data['notes'] ?? null, ); $data['notes'] = null; $this->normalizeOwnerData($data); $position = max(1, (int) ($data['position'] ?? 1)); unset($data['position']); $data['company_id'] = (int) company_id(); $data['created_by'] = user_id(); $data['updated_by'] = user_id(); $this->normalizeCompletionDates($data); $insertedSequence = $position; $todo = DB::transaction(function () use ($data, $position, &$insertedSequence): SystemizationTodo { $siblings = $this->contentSiblings($data['category'] ?? null, $data['section'] ?? null); $nextOrder = (int) SystemizationTodo::query()->max('sort_order') + 1; if ($siblings->isEmpty()) { $insertOrder = $nextOrder; } elseif ($position <= $siblings->count()) { $insertOrder = (int) $siblings[$position - 1]->sort_order; } else { $insertOrder = (int) $siblings->max('sort_order') + 1; } SystemizationTodo::query() ->where('sort_order', '>=', $insertOrder) ->increment('sort_order'); $insertedSequence = $siblings->isEmpty() ? 1 : min($position, $siblings->count() + 1); $todo = SystemizationTodo::query()->create([ ...$data, 'sort_order' => $insertOrder, ]); $this->audit->record( 'create', (int) company_id(), (int) $todo->id, null, $this->audit->snapshot($todo), user_id(), null, ['source' => 'manual'], ); return $todo; }); $this->storeAttachments($request, $todo); return $this->backToPage( '待办事项已新增为当前内容第 ' . $insertedSequence . ' 条并定位。', 'success', $todo, true, ); } public function updateCategory(Request $request, SystemizationTodo $todo): RedirectResponse { $data = $request->validate([ 'category' => ['required', 'string', 'max:100'], ]); $ids = []; DB::transaction(function () use ($todo, $data, &$ids): void { $ids = $this->groupTodoIds($todo, false); $this->updateGroupField($ids, ['category' => trim($data['category'])]); }); return $this->backToPage( '类型已更新,当前类型下的 ' . count($ids) . ' 条待办已同步调整。', 'success', $todo, ); } public function updateSection(Request $request, SystemizationTodo $todo): RedirectResponse { $data = $request->validate([ 'section' => ['required', 'string', 'max:255'], ]); $ids = []; DB::transaction(function () use ($todo, $data, &$ids): void { $ids = $this->groupTodoIds($todo, true); $this->updateGroupField($ids, ['section' => trim($data['section'])]); }); return $this->backToPage( '内容已更新,当前内容下的 ' . count($ids) . ' 条待办已同步调整。', 'success', $todo, ); } private function contentSiblings( ?string $category, ?string $section, ?int $excludeId = null, ): Collection { $categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($category)); $sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($section)); return SystemizationTodo::query() ->orderBy('sort_order') ->orderBy('id') ->lockForUpdate() ->get() ->filter(fn (SystemizationTodo $todo): bool => (int) $todo->id !== (int) $excludeId && $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)) === $categoryKey && $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)) === $sectionKey ) ->values(); } private function positionForUpdate( SystemizationTodo $todo, int $targetPosition, array &$data, ): void { $currentCategoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)); $currentSectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)); $targetCategoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($data['category'] ?? null)); $targetSectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($data['section'] ?? null)); $sameGroup = $currentCategoryKey === $targetCategoryKey && $currentSectionKey === $targetSectionKey; if ($sameGroup) { $currentSiblings = $this->contentSiblings($todo->category, $todo->section); $currentIndex = $currentSiblings->search( static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $todo->id ); if ($currentIndex !== false && $targetPosition === ((int) $currentIndex + 1)) { return; } } $siblings = $this->contentSiblings( $data['category'] ?? null, $data['section'] ?? null, (int) $todo->id, ); if ($siblings->isEmpty()) { $insertOrder = (int) SystemizationTodo::query()->max('sort_order') + 1; } elseif ($targetPosition <= $siblings->count()) { $insertOrder = (int) $siblings[$targetPosition - 1]->sort_order; } else { $insertOrder = (int) $siblings->max('sort_order') + 1; } SystemizationTodo::query() ->where('sort_order', '>=', $insertOrder) ->increment('sort_order'); $data['sort_order'] = $insertOrder; } private function groupTodoIds(SystemizationTodo $todo, bool $includeSection): array { $categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)); $sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)); return SystemizationTodo::query() ->lockForUpdate() ->get(['id', 'category', 'section']) ->filter(fn (SystemizationTodo $candidate): bool => $this->hierarchyGroupKey($this->hierarchyLabel($candidate->category)) === $categoryKey && (! $includeSection || $this->hierarchyGroupKey($this->hierarchyLabel($candidate->section)) === $sectionKey) ) ->pluck('id') ->map(static fn (mixed $id): int => (int) $id) ->values() ->all(); } private function updateGroupField(array $ids, array $data): void { if ($ids === []) { return; } $todos = SystemizationTodo::query() ->whereIn('id', $ids) ->lockForUpdate() ->get(); $auditEvents = []; foreach ($todos as $todo) { $before = $this->audit->snapshot($todo); $todo->fill([ ...$data, 'updated_by' => user_id(), ])->save(); $auditEvents[] = [ 'company_id' => (int) company_id(), 'todo_id' => (int) $todo->id, 'action' => 'update', 'before' => $before, 'after' => $this->audit->snapshot($todo), 'user_id' => user_id(), 'meta' => ['source' => 'manual', 'scope' => 'group'], ]; } $this->audit->recordMany($auditEvents); } public function update(Request $request, SystemizationTodo $todo): RedirectResponse { $before = $this->audit->snapshot($todo, true); $data = $this->validated($request); $this->normalizePendingDates($request, $data); $data['progress'] = SystemizationTodo::mergeNotesIntoProgress( array_key_exists('progress', $data) ? $data['progress'] : $todo->progress, array_key_exists('notes', $data) ? $data['notes'] : $todo->notes, ); $data['notes'] = null; $this->normalizeOwnerData($data); $targetPosition = max(0, (int) ($data['position'] ?? 0)); unset($data['position']); $data['updated_by'] = user_id(); $this->normalizeCompletionDates($data); $removeIds = collect($request->input('remove_attachments', [])) ->map(static fn (mixed $id): int => (int) $id) ->filter() ->unique() ->values(); if ($removeIds->isNotEmpty()) { $attachments = $todo->attachments() ->where('company_id', (int) company_id()) ->whereIn('id', $removeIds) ->get(); foreach ($attachments as $attachment) { Storage::disk('uploads')->delete($attachment->path); $attachment->delete(); } } DB::transaction(function () use ($todo, $data, $targetPosition): void { $lockedTodo = SystemizationTodo::query() ->lockForUpdate() ->findOrFail($todo->id); if ($targetPosition > 0) { $this->positionForUpdate($lockedTodo, $targetPosition, $data); } $lockedTodo->update($data); $todo->setRawAttributes($lockedTodo->getAttributes(), true); }); $this->storeAttachments($request, $todo); $todo->refresh(); $after = $this->audit->snapshot($todo, true); if ($before !== $after) { $this->audit->record( 'update', (int) company_id(), (int) $todo->id, $before, $after, user_id(), null, ['source' => 'manual'], ); } return $this->backToPage('待办事项已更新并定位。', 'success', $todo); } public function destroy(SystemizationTodo $todo): RedirectResponse { DB::transaction(function () use ($todo): void { $before = $this->audit->snapshot($todo, true); $todo->update(['updated_by' => user_id()]); $todo->delete(); $this->audit->record( 'delete', (int) company_id(), (int) $todo->id, $before, null, user_id(), null, ['source' => 'manual'], ); }); 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), ); } $result['grid'] = $this->gridRefreshPayload(); return response()->json($result); } private function gridRefreshPayload(): array { $filters = $this->filters(); $sequenceMap = $this->hierarchySequenceMap(); $items = $this->decorateGridItems($this->filteredItems($filters), $sequenceMap); $categorySpans = $this->gridGroupRowspans( $items, static fn (SystemizationTodo $todo): string => (string) ((int) $todo->type_sequence), ); $sectionSpans = $this->gridGroupRowspans( $items, static fn (SystemizationTodo $todo): string => (int) $todo->type_sequence . "\0" . (int) $todo->content_sequence, ); return [ 'rows' => $items->values()->map( static fn (SystemizationTodo $todo, int $index): array => [ 'id' => (int) $todo->id, 'position' => (int) $todo->item_sequence, 'content_count' => (int) $todo->content_count, 'type_sequence' => (int) $todo->type_sequence, 'content_sequence' => (int) $todo->content_sequence, 'category' => $todo->category ?? '', 'category_display' => $todo->category_display ?: '', 'category_span' => $categorySpans[$index] ?? 0, 'section' => $todo->section_display ?? '', 'section_span' => $sectionSpans[$index] ?? 0, 'title' => trim((string) preg_replace( '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u', '', (string) $todo->title, )), 'owner' => $todo->owner_display ?? '', 'assignee' => $todo->assignee ?? '', 'start_date' => $todo->start_date?->format('Y-m-d') ?? '', 'start_note' => $todo->start_note ?? '', 'due_date' => $todo->due_date?->format('Y-m-d') ?? '', 'due_note' => $todo->due_note ?? '', 'reminder_label' => $todo->reminder_label, 'reminder_tone' => $todo->reminder_tone, 'status' => $todo->status_label, 'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '', 'progress' => $todo->progress ?? '', 'progress_display' => $todo->displayProgress(), ], )->all(), 'summary' => $this->todoSummary(), ]; } /** * @param Collection $items * @return array */ private function gridGroupRowspans(Collection $items, callable $key): array { $spans = array_fill(0, $items->count(), 0); $items = $items->values(); $start = 0; $previous = null; $applySpan = static function (int $from, int $to) use (&$spans): void { if ($to < $from || $to < 0) { return; } $spans[$from] = $to - $from + 1; }; foreach ($items as $index => $item) { $current = $key($item); if ($index === 0) { $previous = $current; continue; } if ($current === $previous) { continue; } $applySpan($start, $index - 1); $start = $index; $previous = $current; } if ($items->isNotEmpty()) { $applySpan($start, $items->count() - 1); } return $spans; } 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(Request $request, SystemizationTodo $todo): JsonResponse { $filters = $this->filters(); $filterDefaults = $this->gridFilterDefaults($filters); $createdId = DB::transaction(function () use ($todo, $filterDefaults): 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' => '新待办', ...$filterDefaults, 'start_note' => null, 'due_note' => 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); } $created = SystemizationTodo::query()->findOrFail($createdId); $clearFilters = $this->conflictingFilterQueryKeys($created, $filters); return response()->json([ 'ok' => true, 'id' => $createdId, 'clear_filters' => $clearFilters, 'message' => '已插入空白待办行。', ]); } public function createGridRow(Request $request): JsonResponse { $data = $request->validate([ 'mode' => ['required', Rule::in(['type', 'content', 'todo'])], 'category' => ['nullable', 'string', 'max:100'], 'section' => ['nullable', 'string', 'max:255'], ]); $filters = $this->filters(); $filterDefaults = $this->gridFilterDefaults($filters); $createdId = DB::transaction(function () use ($data, $filterDefaults): int { $mode = (string) $data['mode']; $category = trim((string) ($data['category'] ?? '')); $section = trim((string) ($data['section'] ?? '')); if ($mode === 'type') { $categories = SystemizationTodo::query() ->whereNotNull('category') ->pluck('category') ->map(fn (mixed $value): string => $this->hierarchyLabel((string) $value)); $category = $this->nextUniqueGridLabel($categories, '新类型'); $section = $this->nextUniqueGridLabel(collect(), '新内容'); } elseif ($mode === 'content') { $categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($category)); $sections = SystemizationTodo::query() ->whereNotNull('section') ->get(['category', 'section']) ->filter(fn (SystemizationTodo $todo): bool => $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)) === $categoryKey ) ->pluck('section') ->map(fn (mixed $value): string => $this->hierarchyLabel((string) $value)); $section = $this->nextUniqueGridLabel($sections, '新内容'); } elseif ($mode === 'todo') { $section = trim((string) ($data['section'] ?? '')); } $category = $category === '' ? null : $category; $section = $section === '' ? null : $section; if ($mode === 'todo') { $siblings = $this->contentSiblings($category, $section); $insertOrder = $siblings->isEmpty() ? (int) SystemizationTodo::query()->max('sort_order') + 1 : (int) $siblings->max('sort_order') + 1; } else { $insertOrder = (int) SystemizationTodo::query()->max('sort_order') + 1; } if ($mode === 'todo') { SystemizationTodo::query() ->where('sort_order', '>=', $insertOrder) ->increment('sort_order'); } $created = SystemizationTodo::query()->create([ 'company_id' => (int) company_id(), 'category' => $category, 'section' => $section, 'title' => '新待办', ...$filterDefaults, 'start_note' => null, 'due_note' => 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' => 'create_' . $mode], ); return (int) $created->id; }); $created = SystemizationTodo::query()->findOrFail($createdId); return response()->json([ 'ok' => true, 'id' => $createdId, 'clear_filters' => $this->conflictingFilterQueryKeys($created, $filters), 'message' => '已新增空白待办行。', ]); } private function gridFilterDefaults(array $filters): array { $status = match (true) { $filters['status'] === SystemizationTodo::STATUS_COMPLETED, $filters['reminder'] === 'none' => SystemizationTodo::STATUS_COMPLETED, default => SystemizationTodo::STATUS_INCOMPLETE, }; $startDate = $filters['start_date'] !== '' ? $filters['start_date'] : null; $dueDate = $filters['due_date'] !== '' ? $filters['due_date'] : match ($filters['reminder']) { 'overdue' => today()->subDay()->toDateString(), 'today' => today()->toDateString(), 'upcoming' => today()->addDay()->toDateString(), default => null, }; return [ 'owner' => $filters['owners'][0] ?? '待定', 'assignee' => $filters['assignees'][0] ?? null, 'start_date' => $startDate, 'due_date' => $dueDate, 'status' => $status, 'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED ? ($dueDate ?: today()->toDateString()) : null, ]; } /** * @return array */ private function conflictingFilterQueryKeys(SystemizationTodo $todo, array $filters): array { $conflicts = []; foreach (self::FILTER_QUERY_KEYS as $filterKey => $queryKey) { if ($filters[$filterKey] === '' || $filters[$filterKey] === []) { continue; } $singleFilter = $this->emptyFilters(); $singleFilter[$filterKey] = $filters[$filterKey]; if (! $this->todoMatchesFilters($todo, $singleFilter)) { $conflicts[] = $queryKey; } } if ($conflicts === [] && ! $this->todoMatchesFilters($todo, $filters)) { foreach (self::FILTER_QUERY_KEYS as $filterKey => $queryKey) { if ($filters[$filterKey] !== '' && $filters[$filterKey] !== []) { $conflicts[] = $queryKey; } } } return array_values(array_unique($conflicts)); } private function todoMatchesFilters(SystemizationTodo $todo, array $filters): bool { $completed = $todo->statusLabel() === SystemizationTodo::STATUS_COMPLETED; $overdue = ! $completed && $todo->due_date !== null && $todo->due_date->lt(today()->startOfDay()); if ($filters['status'] === self::OVERDUE_FILTER && ! $overdue) { return false; } if ( in_array($filters['status'], [ SystemizationTodo::STATUS_COMPLETED, SystemizationTodo::STATUS_INCOMPLETE, ], true) && $todo->statusLabel() !== $filters['status'] ) { return false; } $reminderMatches = match ($filters['reminder']) { 'overdue' => $overdue, 'today' => ! $completed && $todo->due_date?->isSameDay(today()) === true, 'upcoming' => ! $completed && $todo->due_date?->gt(today()->startOfDay()) === true, 'pending' => ! $completed && $todo->due_date === null, 'none' => $completed, default => true, }; if (! $reminderMatches) { return false; } if ($filters['category'] !== '' && (string) $todo->category !== $filters['category']) { return false; } if ( $filters['owners'] !== [] && array_intersect($this->ownerNames($todo->owner), $filters['owners']) === [] ) { return false; } if ($filters['assignees'] !== []) { $assignee = (string) ($todo->assignee ?? ''); $matchedAssignee = collect($filters['assignees'])->contains( static fn (string $name): bool => mb_stripos($assignee, $name, 0, 'UTF-8') !== false, ); if (! $matchedAssignee) { return false; } } if ( $filters['start_date'] !== '' && $todo->start_date?->format('Y-m-d') !== $filters['start_date'] ) { return false; } if ( $filters['due_date'] !== '' && $todo->due_date?->format('Y-m-d') !== $filters['due_date'] ) { return false; } if ($filters['keyword'] !== '') { $haystack = implode(' ', [ (string) ($todo->title ?? ''), (string) ($todo->category ?? ''), (string) ($todo->section ?? ''), (string) ($todo->owner ?? ''), (string) ($todo->assignee ?? ''), (string) ($todo->progress ?? ''), ]); if (mb_stripos($haystack, $filters['keyword'], 0, 'UTF-8') === false) { return false; } } return true; } private function emptyFilters(): array { return [ 'status' => '', 'reminder' => '', 'category' => '', 'owners' => [], 'assignees' => [], 'start_date' => '', 'due_date' => '', 'keyword' => '', ]; } public function uploadGridAttachments(Request $request, SystemizationTodo $todo): JsonResponse { $request->validate([ 'screenshots' => ['required', 'array', 'min:1', 'max:12'], 'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'], ]); $before = $this->audit->snapshot($todo, true); DB::transaction(function () use ($request, $todo): void { $this->storeAttachments($request, $todo); $todo->update(['updated_by' => user_id()]); }); $todo->refresh(); $after = $this->audit->snapshot($todo, true); $this->audit->record( 'update', (int) company_id(), (int) $todo->id, $before, $after, user_id(), null, ['source' => 'grid', 'operation' => 'upload_attachments'], ); return response()->json([ 'ok' => true, 'attachment_count' => $after['attachment_count'] ?? 0, ]); } public function destroyGridAttachment( SystemizationTodo $todo, SystemizationTodoAttachment $attachment, ): JsonResponse { abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404); abort_unless((int) $attachment->company_id === (int) company_id(), 404); $before = $this->audit->snapshot($todo, true); DB::transaction(function () use ($todo, $attachment): void { Storage::disk('uploads')->delete($attachment->path); $attachment->delete(); $todo->update(['updated_by' => user_id()]); }); $todo->refresh(); $after = $this->audit->snapshot($todo, true); $this->audit->record( 'update', (int) company_id(), (int) $todo->id, $before, $after, user_id(), null, ['source' => 'grid', 'operation' => 'delete_attachment'], ); return response()->json([ 'ok' => true, 'attachment_count' => $after['attachment_count'] ?? 0, ]); } public function history(): JsonResponse { $from = $this->historyStartAt(); $events = $this->audit->history( (int) company_id(), SystemizationTodoAuditLogger::HISTORY_LIMIT, $from, ); return response()->json([ 'events' => $events, 'total' => count($events), 'limit' => SystemizationTodoAuditLogger::HISTORY_LIMIT, 'from' => $from, ]); } public function historyExport(Request $request): StreamedResponse { $action = trim((string) $request->query('action', '')); $keyword = trim((string) $request->query('keyword', '')); $from = $this->historyStartAt(); $rows = array_values(array_filter( $this->audit->history( (int) company_id(), SystemizationTodoAuditLogger::HISTORY_LIMIT, $from, ), fn (array $row): bool => $this->historyMatches($row, $action, $keyword), )); return response()->streamDownload(function () use ($rows): void { $handle = fopen('php://output', 'w'); fwrite($handle, "\xEF\xBB\xBF"); fputcsv($handle, ['时间', '操作', '类型', '内容', '待办事项', '变更内容', '操作人']); foreach ($rows as $row) { fputcsv($handle, [ $row['at'], $row['action_label'], $row['category'], $row['section'], $row['title'], implode(';', $row['changes']), $row['actor'], ]); } fclose($handle); }, '系统化待办_操作记录.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } private function historyStartAt(): string { return $this->audit->platformNow()->startOfDay()->toDateTimeString(); } private function historyMatches(array $row, string $action, string $keyword): bool { if ($action !== '' && (string) ($row['action'] ?? '') !== $action) { return false; } if ($keyword === '') { return true; } $haystack = implode(' ', [ (string) ($row['category'] ?? ''), (string) ($row['section'] ?? ''), (string) ($row['title'] ?? ''), (string) ($row['actor'] ?? ''), implode(' ', (array) ($row['changes'] ?? [])), ]); return mb_stripos($haystack, $keyword, 0, 'UTF-8') !== false; } public function attachment(SystemizationTodo $todo, SystemizationTodoAttachment $attachment): StreamedResponse { abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404); abort_unless((int) $attachment->company_id === (int) company_id(), 404); $disk = Storage::disk('uploads'); abort_unless($disk->exists($attachment->path), 404, '截图不存在。'); return $disk->response($attachment->path, $attachment->original_name, [ 'Cache-Control' => 'private, no-store', 'Content-Type' => $attachment->mime_type, 'X-Content-Type-Options' => 'nosniff', ]); } public function importWorkbook( Request $request, SystemizationTodoWorkbookImporter $importer, ): RedirectResponse { $data = $request->validate([ 'source' => ['required', 'file', 'mimes:xlsx', 'max:20480'], ]); try { $result = $importer->sync( $data['source']->getRealPath(), (int) company_id(), user_id(), false, $data['source']->getClientOriginalName(), ); } catch (Throwable $exception) { report($exception); return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error'); } return $this->backToPage(sprintf( '同步完成:%s,新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。', $result['sheet'], $result['created'], $result['updated'], $result['retained'], )); } private function filters(): array { return [ 'status' => $this->normalizeStatusValue(request('status')), 'reminder' => $this->normalizeReminderValue(request('reminder')), 'category' => trim((string) request('category', '')), 'owners' => $this->normalizeOwnerFilterValues($this->peopleFilter('owner')), 'assignees' => $this->peopleFilter('assignee'), '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 normalizeReminderValue(mixed $value): string { $reminder = trim((string) $value); return array_key_exists($reminder, self::REMINDER_OPTIONS) ? $reminder : ''; } private function peopleFilter(string $key): array { return collect((array) request($key, [])) ->filter(static fn (mixed $value): bool => is_string($value)) ->map(static fn (string $value): string => trim($value)) ->filter() ->unique() ->take(50) ->values() ->all(); } private function peopleOptions(string $column): Collection { return SystemizationTodo::query() ->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 assigneeOwnerMap(Collection $assignees): array { $derived = []; SystemizationTodo::query() ->whereNotNull('assignee') ->where('assignee', '<>', '') ->get(['owner', 'assignee']) ->each(function (SystemizationTodo $todo) use (&$derived): void { $owners = $this->ownerNames($todo->owner); foreach ($this->personNames($todo->assignee) as $assignee) { foreach ($owners as $owner) { $derived[$assignee][$owner] = true; } } }); $map = []; foreach ($assignees as $assignee) { if (isset(self::OWNER_ALIASES[$assignee])) { $map[$assignee] = [self::OWNER_ALIASES[$assignee]]; continue; } $ownerSet = $derived[$assignee] ?? []; $map[$assignee] = array_values(array_filter( self::OWNER_OPTIONS, static fn (string $owner): bool => isset($ownerSet[$owner]), )); } return $map; } private function personNames(?string $value): array { return array_values(array_unique(array_filter( array_map('trim', preg_split('/[、,,;;\/]+/u', trim((string) $value)) ?: []), static fn (string $name): bool => $name !== '' && $name !== '—', ))); } private function normalizeOwnerFilterValues(array $owners): array { return collect($owners) ->map(static fn (string $owner): ?string => self::OWNER_ALIASES[$owner] ?? null) ->filter() ->unique() ->values() ->all(); } private function applyOwnerFilter($builder, string $owner): void { $aliases = array_keys(array_filter( self::OWNER_ALIASES, static fn (string $canonical): bool => $canonical === $owner, )); if ($aliases !== []) { $builder->orWhere(function ($aliasQuery) use ($aliases): void { foreach ($aliases as $alias) { $aliasQuery->orWhere( 'owner', 'like', '%' . addcslashes($alias, '%_\\') . '%', ); } }); } if ($owner !== '待定') { return; } $knownAliases = array_values(array_diff(array_keys(self::OWNER_ALIASES), ['待定'])); $builder->orWhere(function ($pendingQuery) use ($knownAliases): void { $pendingQuery->whereNull('owner')->orWhere('owner', ''); if ($knownAliases === []) { return; } $pendingQuery->orWhere(function ($unknownQuery) use ($knownAliases): void { foreach ($knownAliases as $alias) { $unknownQuery->where( 'owner', 'not like', '%' . addcslashes($alias, '%_\\') . '%', ); } }); }); } private function ownerNames(?string $value): array { $names = collect(preg_split('/[、,,;;\/]+/u', trim((string) $value)) ?: []) ->map(static fn (string $name): string => trim($name)) ->filter(static fn (string $name): bool => $name !== '' && $name !== '—') ->map(static fn (string $name): string => self::OWNER_ALIASES[$name] ?? '待定') ->unique() ->values() ->all(); return $names === [] ? ['待定'] : $names; } private function normalizeOwnerData(array &$data): void { if (! array_key_exists('owner', $data)) { return; } $data['owner'] = implode('、', $this->ownerNames($data['owner'])); } private function contentOptions(): array { $options = []; $todos = SystemizationTodo::query() ->whereNotNull('section') ->where('section', '<>', '') ->get(['category', 'section']); foreach ($todos as $todo) { $category = $this->hierarchyLabel($todo->category); $section = $this->hierarchyLabel($todo->section); if ($category !== '' && $section !== '') { $options[$category][$section] = true; } } foreach ($options as $category => $sections) { $labels = array_keys($sections); usort($labels, 'strnatcasecmp'); $options[$category] = $labels; } return $options; } private function normalizeDateValue(mixed $value): string { $value = trim((string) $value); if ($value === '') { return ''; } $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); return $date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value ? $value : ''; } private function gridFieldSupportsGroupScope(string $field): bool { return in_array($field, ['category', 'section'], true); } /** * @param Collection $existing */ private function nextUniqueGridLabel(Collection $existing, string $base): string { $used = $existing ->map(fn (mixed $value): string => $this->hierarchyGroupKey($this->hierarchyLabel((string) $value))) ->filter() ->flip(); for ($index = 1; $index <= 10000; $index++) { $candidate = $base . ' ' . $index; if (! $used->has($this->hierarchyGroupKey($candidate))) { return $candidate; } } return $base . ' ' . Str::lower(Str::random(8)); } 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); $todo->progress = SystemizationTodo::mergeNotesIntoProgress($todo->progress, $todo->notes); $todo->notes = null; $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([ 'category' => ['nullable', 'string', 'max:100'], 'section' => ['nullable', 'string', 'max:255'], 'title' => ['required', 'string', 'max:5000'], 'position' => ['nullable', 'integer', 'min:1'], 'owner' => ['nullable', 'string', 'max:255'], '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'], 'notes' => ['nullable', 'string', 'max:10000'], 'screenshots' => ['nullable', 'array', 'max:12'], 'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'], 'remove_attachments' => ['nullable', 'array'], 'remove_attachments.*' => ['integer'], ]); } 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; $data['completed_at'] = $completed ? (($data['completed_at'] ?? null) ?: ($data['due_date'] ?? null) ?: now()->toDateString()) : null; } private function storeAttachments(Request $request, SystemizationTodo $todo): void { foreach ($request->file('screenshots', []) as $file) { $extension = strtolower($file->getClientOriginalExtension()); $fileName = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : ''); $path = Storage::disk('uploads')->putFileAs( 'systemization-todos/' . (int) company_id() . '/' . $todo->id, $file, $fileName, ); if ($path === false) { throw new \RuntimeException('完成截图保存失败。'); } SystemizationTodoAttachment::query()->create([ 'company_id' => (int) company_id(), 'systemization_todo_id' => $todo->id, 'path' => $path, 'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 240), 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'size' => $file->getSize() ?: 0, 'uploaded_by' => user_id(), ]); } } private function backToPage( string $message, string $type = 'success', ?SystemizationTodo $focus = null, bool $clearConflictingFilters = false, ): RedirectResponse { $parameters = [ 'section' => 'other', 'page' => 'systemization-todos', ]; $filters = $this->returnedFilters(); $parameters = array_merge($parameters, $filters); if ($focus) { $parameters['highlight'] = $focus->id; if ($clearConflictingFilters) { $conflicts = $this->conflictingFilterQueryKeys( $focus, $this->filtersFromQuery($filters), ); foreach ($conflicts as $conflict) { unset($parameters[$conflict]); } if ($conflicts !== []) { $message = '新行已创建,为显示它已暂时清除冲突筛选'; } } } return redirect() ->route('business.pages.show', $parameters) ->with($type === 'success' ? 'success' : 'error', $message); } private function filtersFromQuery(array $filters): array { return [ 'status' => $this->normalizeStatusValue($filters['status'] ?? null), 'reminder' => $this->normalizeReminderValue($filters['reminder'] ?? null), 'category' => trim((string) ($filters['category'] ?? '')), 'owners' => $this->normalizeOwnerFilterValues((array) ($filters['owner'] ?? [])), 'assignees' => collect((array) ($filters['assignee'] ?? [])) ->filter(static fn (mixed $value): bool => is_string($value)) ->map(static fn (string $value): string => trim($value)) ->filter() ->unique() ->take(50) ->values() ->all(), 'start_date' => $this->normalizeDateValue($filters['start_date'] ?? null), 'due_date' => $this->normalizeDateValue($filters['due_date'] ?? null), 'keyword' => trim((string) ($filters['keyword'] ?? '')), ]; } 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(); $owners = $this->normalizeOwnerFilterValues($owners); $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), 'reminder' => $this->normalizeReminderValue($filters['reminder'] ?? 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 !== []); } }