user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], 'keyword' => ['nullable', 'string', 'max:200'], 'track_code' => ['nullable', 'string', 'max:64'], 'review_status' => ['nullable', 'string', 'in:pending,reviewed'], 'page' => ['nullable', 'integer', 'min:1'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); $allowedCodes = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->pluck('track_code') ->unique() ->filter() ->values(); if ($allowedCodes->isEmpty()) { abort(403, '您暂无本场赛事的评审范围'); } $tracksMeta = CompetitionTrack::query() ->where('competition_id', $competition->id) ->whereIn('track_code', $allowedCodes) ->orderBy('sort') ->orderBy('id') ->get(['id', 'track_code', 'title']); $trackTitleByCode = $tracksMeta->keyBy('track_code'); $q = Application::query() ->where('competition_id', $competition->id) ->where('status', 'submitted') ->whereIn('track', $allowedCodes); if (! empty($data['track_code'])) { if (! $allowedCodes->contains($data['track_code'])) { abort(422, '无效赛道'); } $q->where('track', $data['track_code']); } if (isset($data['keyword']) && $data['keyword'] !== null && trim($data['keyword']) !== '') { $raw = trim($data['keyword']); $kw = '%'.addcslashes($raw, '%_\\').'%'; $q->where(function ($w) use ($kw): void { $w->where('player_name', 'like', $kw) ->orWhere('project_name', 'like', $kw) ->orWhere('school', 'like', $kw) ->orWhere('company_name', 'like', $kw) ->orWhere('entry_group', 'like', $kw) ->orWhere('contact_mobile', 'like', $kw) ->orWhereRaw( "CONCAT_WS(' ', COALESCE(location_country,''), COALESCE(location_province,''), COALESCE(location_city,''), COALESCE(oversea_country,'')) LIKE ?", [$kw] ); }); } $reviewStatus = $data['review_status'] ?? null; if ($reviewStatus === 'pending') { $q->whereDoesntHave('reviewScores', function ($w) use ($reviewer): void { $w->where('reviewer_id', $reviewer->id); }); } elseif ($reviewStatus === 'reviewed') { $q->whereHas('reviewScores', function ($w) use ($reviewer): void { $w->where('reviewer_id', $reviewer->id); }); } $perPage = isset($data['per_page']) ? (int) $data['per_page'] : 20; $paginator = $q->orderByDesc('submitted_at') ->orderByDesc('id') ->paginate($perPage); $appIds = collect($paginator->items())->pluck('id')->all(); $scoresByAppId = ApplicationReviewScore::query() ->where('reviewer_id', $reviewer->id) ->whereIn('application_id', $appIds) ->get() ->keyBy('application_id'); $rows = []; foreach ($paginator->items() as $app) { /** @var Application $app */ $rows[] = $this->serializeListRow($app, $trackTitleByCode, $scoresByAppId->get($app->id)); } return response()->json([ 'data' => $rows, 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'tracks' => $tracksMeta->map(fn (CompetitionTrack $t) => [ 'track_code' => $t->track_code, 'title' => $t->title, ])->values(), ]); } /** * 单条报名详情(评审端只读)。 */ public function show(Request $request, Application $application): JsonResponse { /** @var Reviewer $reviewer */ $reviewer = $request->user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); if ($application->competition_id !== $competition->id) { abort(404); } if ($application->status !== 'submitted') { abort(404); } $hasScope = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->exists(); if (! $hasScope) { abort(403, '您无权查看该项目'); } $application->load(['files']); $track = CompetitionTrack::query() ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->first(); $scoringSheet = $this->resolveTrackScoringSheet($track); $myScore = ApplicationReviewScore::query() ->where('application_id', $application->id) ->where('reviewer_id', $reviewer->id) ->first(); return response()->json([ 'data' => $this->serializeDetail( $application, (string) ($track?->title ?? $application->track ?? ''), $competition, $scoringSheet, $myScore ), ]); } /** * 评审员提交或更新本人对该报名的打分(payload 与赛事生效的评审 Schema 一致)。 */ public function submitScore(Request $request, Application $application): JsonResponse { /** @var Reviewer $reviewer */ $reviewer = $request->user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], 'payload' => ['required', 'array'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); if ($application->competition_id !== $competition->id) { abort(404); } if ($application->status !== 'submitted') { abort(404); } $hasScope = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->exists(); if (! $hasScope) { abort(403, '您无权评审该项目'); } $track = CompetitionTrack::query() ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->first(); $scoringSheet = $this->resolveTrackScoringSheet($track); if ($scoringSheet === null) { throw ValidationException::withMessages([ 'payload' => ['该赛道未配置打分表,暂不可评审'], ]); } $normalized = TrackScoringSheet::normalizeSubmitPayload($scoringSheet, $data['payload']); if ($normalized['ok'] !== true) { throw ValidationException::withMessages([ 'payload' => [$normalized['message']], ]); } $lineTotal = $normalized['line_total']; $payloadJson = $normalized['payload']; DB::transaction(function () use ($application, $reviewer, $payloadJson, $lineTotal): void { ApplicationReviewScore::query()->updateOrCreate( [ 'application_id' => $application->id, 'reviewer_id' => $reviewer->id, ], [ 'review_schema_id' => null, 'payload_json' => $payloadJson, 'line_total' => $lineTotal, ] ); ApplicationReviewRecord::query()->firstOrCreate( ['application_id' => $application->id] ); }); $myScore = ApplicationReviewScore::query() ->where('application_id', $application->id) ->where('reviewer_id', $reviewer->id) ->first(); return response()->json([ 'message' => '评分已保存', 'data' => [ 'my_review_score' => $this->serializeMyReviewScore($myScore), 'score_display' => $this->formatScoreDisplay(true, $lineTotal), 'score_is_pending' => false, 'scoring_allowed' => true, ], ]); } /** * 评审端下载附件(Content-Disposition 使用用户上传时的原始文件名)。 */ public function downloadFile(Request $request, Application $application, ApplicationFile $file): StreamedResponse { /** @var Reviewer $reviewer */ $reviewer = $request->user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); if ($application->competition_id !== $competition->id || $file->application_id !== $application->id) { abort(404); } if ($application->status !== 'submitted') { abort(404); } $hasScope = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->exists(); if (! $hasScope) { abort(403, '您无权下载该附件'); } return Storage::disk($file->disk)->download( $file->path, $file->clientDownloadName(), ['Cache-Control' => 'private, no-store'] ); } /** * @param \Illuminate\Support\Collection $trackTitleByCode */ private function serializeListRow(Application $app, $trackTitleByCode, ?ApplicationReviewScore $myScore): array { $code = $app->track ?? ''; $trackTitle = $trackTitleByCode->get($code)?->title ?? $code; $hasMine = $myScore !== null; $line = $myScore?->line_total; return [ 'id' => $app->id, 'project_code' => \App\Support\ProjectCode::resolve($app), 'project_name' => $app->project_name ?? '', 'player_name' => $app->player_name ?? '', 'location_label' => $this->formatLocation($app), 'school' => $app->school ?? '', 'degree' => $app->degree ?? '', 'track_code' => $code, 'track_title' => $trackTitle, 'score_display' => $this->formatScoreDisplay($hasMine, $line), 'score_is_pending' => ! $hasMine, 'manage_status' => null, 'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at), 'competition_id' => $app->competition_id, ]; } /** * @param array{version: int, title: string, full_score: float, items: list}|null $scoringSheet */ private function serializeDetail( Application $app, string $trackTitle, Competition $competition, ?array $scoringSheet, ?ApplicationReviewScore $myScore ): array { $hasMine = $myScore !== null; $line = $myScore?->line_total; $scoringAllowed = $scoringSheet !== null; return [ 'id' => $app->id, 'project_code' => \App\Support\ProjectCode::resolve($app), 'project_name' => $app->project_name ?? '', 'player_name' => $app->player_name ?? '', 'school' => $app->school ?? '', 'degree' => $app->degree ?? '', 'contact_email' => $app->contact_email ?? '', 'contact_mobile' => $app->contact_mobile ?? '', 'entry_group' => $app->entry_group ?? '', 'company_name' => $app->company_name ?? '', 'track_code' => $app->track ?? '', 'track_title' => $trackTitle, 'location_country' => $app->location_country ?? '', 'location_province' => $app->location_province ?? '', 'location_city' => $app->location_city ?? '', 'oversea_country' => $app->oversea_country ?? '', 'location_label' => $this->formatLocation($app), 'recommend' => $app->recommend ?? '', 'intro' => $app->intro ?? '', 'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at), 'score_display' => $this->formatScoreDisplay($hasMine, $line), 'score_is_pending' => ! $hasMine, 'scoring_allowed' => $scoringAllowed, 'scoring_sheet' => $scoringSheet, 'scoring_blocked_reason' => $scoringAllowed ? null : '该赛道未配置打分表,暂不可评审', // 兼容旧前端字段:不再使用赛事级评审 Schema 'review_schema' => [ 'id' => null, 'schema_json' => [], 'is_default' => false, ], 'my_review_score' => $this->serializeMyReviewScore($myScore), 'pledge_content_html' => $competition->pledge_content_html ?? '', 'promise_signature' => $app->promise_signature, 'files' => $app->files->map(fn ($f) => [ 'id' => $f->id, 'kind' => $f->kind, 'original_name' => $f->original_name, 'size' => $f->size, 'url' => $f->publicUrl(), ])->values(), 'promise_signed_at' => $this->formatSubmittedAtForDisplay($app->promise_signed_at), 'promise_signed' => $app->promise_signed_at !== null, ]; } /** * @return array{version: int, title: string, full_score: float, items: list}|null */ private function resolveTrackScoringSheet(?CompetitionTrack $track): ?array { if ($track === null) { return null; } $raw = $track->scoring_sheet_json; if (! is_array($raw)) { return null; } $parsed = TrackScoringSheet::parseAndValidateConfig($raw); return $parsed['ok'] === true ? $parsed['sheet'] : null; } private function serializeMyReviewScore(?ApplicationReviewScore $myScore): ?array { if ($myScore === null) { return null; } return [ 'payload_json' => $myScore->payload_json, 'line_total' => (string) $myScore->line_total, 'updated_at' => $this->formatSubmittedAtForDisplay($myScore->updated_at), ]; } private function formatScoreDisplay(bool $hasMine, mixed $lineTotal): string { if (! $hasMine) { return '待评审'; } if ($lineTotal === null) { return '已评分'; } $n = is_numeric($lineTotal) ? (float) $lineTotal : 0.0; return '已评分 · '.rtrim(rtrim(number_format($n, 4, '.', ''), '0'), '.'); } private function formatSubmittedAtForDisplay(?CarbonInterface $dt): string { if ($dt === null) { return ''; } return $dt->copy()->timezone(self::DISPLAY_TIMEZONE)->format('Y-m-d H:i'); } private function formatLocation(Application $app): string { $country = trim((string) ($app->location_country ?? '')); if ($country === '海外' && ($app->oversea_country ?? '') !== '') { return '海外 / '.$app->oversea_country; } $parts = array_values(array_filter([ $app->location_country !== null && $app->location_country !== '' ? $app->location_country : null, $app->location_province !== null && $app->location_province !== '' ? $app->location_province : null, $app->location_city !== null && $app->location_city !== '' ? $app->location_city : null, ], fn ($v) => $v !== null && $v !== '')); return count($parts) ? implode(' / ', $parts) : '—'; } }