filters(); $query = SystemizationTodo::query() ->with('attachments') ->withCount('attachments'); if ($filters['status'] === self::OVERDUE_FILTER) { $query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED]) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()); } elseif ($filters['status'] !== '') { $query->where('status', $filters['status']); } if ($filters['category'] !== '') { $query->where('category', $filters['category']); } if ($filters['owners'] !== []) { $query->where(function ($builder) use ($filters): void { foreach ($filters['owners'] as $owner) { $builder->orWhere( 'owner', 'like', '%' . addcslashes($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) ->orWhere('notes', 'like', $like); }); } $items = $query ->select($query->qualifyColumn('*')) ->selectRaw( "ROW_NUMBER() OVER ( PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__') ORDER BY CASE WHEN status = '已完成' THEN 1 ELSE 0 END, CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END, section, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date, sort_order, id ) AS category_sequence" ) ->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END") ->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END") ->orderBy('category') ->orderByRaw("CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END") ->orderBy('section') ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END') ->orderBy('due_date') ->orderBy('sort_order') ->orderBy('id') ->paginate(50) ->withQueryString(); $base = SystemizationTodo::query(); $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]) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()) ->count(); $statusOptions = collect(); if ($overdueCount > 0) { $statusOptions->push([ 'value' => self::OVERDUE_FILTER, 'label' => '已逾期', ]); } foreach (SystemizationTodo::STATUSES as $status) { if ((int) $statusCounts->get($status, 0) > 0) { $statusOptions->push([ 'value' => $status, 'label' => $status, ]); } } return [ 'view' => 'business.pages.systemization-todos', 'title' => '系统化待办', 'items' => $items, 'todoFilters' => $filters, 'todoCategories' => $categories, 'todoOwners' => $this->peopleOptions('owner'), 'todoAssignees' => $this->peopleOptions('assignee'), 'todoStatuses' => SystemizationTodo::STATUSES, 'todoStatusOptions' => $statusOptions, '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), 'overdue' => $overdueCount, ], 'todoRoutes' => [ 'store' => route('business.systemization-todos.store'), 'update' => route('business.systemization-todos.update', ['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'), ], ]; } public function store(Request $request): RedirectResponse { $data = $this->validated($request); $data['company_id'] = (int) company_id(); $data['sort_order'] = (int) SystemizationTodo::query()->max('sort_order') + 1; $data['created_by'] = user_id(); $data['updated_by'] = user_id(); $this->normalizeCompletionDates($data); $todo = SystemizationTodo::query()->create($data); $this->storeAttachments($request, $todo); return $this->backToPage('待办事项已新增。'); } public function update(Request $request, SystemizationTodo $todo): RedirectResponse { $data = $this->validated($request); $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(); } } $todo->update($data); $this->storeAttachments($request, $todo); return $this->backToPage('待办事项已更新。'); } public function destroy(SystemizationTodo $todo): RedirectResponse { $todo->update(['updated_by' => user_id()]); $todo->delete(); return $this->backToPage('待办事项已删除。'); } 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->import( $data['source']->getRealPath(), (int) company_id(), user_id(), ); } catch (Throwable $exception) { report($exception); return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error'); } return $this->backToPage(sprintf( '导入完成:%s,新增 %d 条,跳过已存在 %d 条。', $result['sheet'], $result['created'], $result['skipped'], )); } private function filters(): array { $status = (string) request('status', ''); return [ 'status' => $status === self::OVERDUE_FILTER || in_array($status, SystemizationTodo::STATUSES, true) ? $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')), 'keyword' => trim((string) request('keyword', '')), ]; } 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 normalizeDateFilter(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 validated(Request $request): array { return $request->validate([ 'category' => ['nullable', 'string', 'max:100'], 'section' => ['nullable', 'string', 'max:255'], 'title' => ['required', 'string', 'max:5000'], 'owner' => ['nullable', 'string', 'max:255'], 'assignee' => ['nullable', 'string', 'max:255'], 'start_date' => ['nullable', 'date_format:Y-m-d'], 'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'], '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 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'): RedirectResponse { return redirect() ->route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]) ->with($type === 'success' ? 'success' : 'error', $message); } }