$filters */ public function buildZipPath(Competition $competition, array $filters): string { $applications = $this->filteredApplications($competition, $filters); $trackTitles = $competition->tracks()->pluck('title', 'track_code'); $xlsxPath = $this->buildXlsxPath($applications, $trackTitles); $tmp = tempnam(sys_get_temp_dir(), 'app_export_'); if ($tmp === false) { @unlink($xlsxPath); throw new \RuntimeException('无法创建临时文件'); } $zipPath = $tmp.'.zip'; @unlink($tmp); $zip = new ZipArchive; if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { @unlink($xlsxPath); throw new \RuntimeException('无法创建 ZIP 文件'); } $zip->addFile($xlsxPath, '报名信息.xlsx'); $usedFolderNames = []; foreach ($applications as $application) { $folder = $this->uniqueFolderName($application, $usedFolderNames); $usedFolderNames[$folder] = true; $zip->addEmptyDir($folder); foreach ($application->files as $file) { $absolutePath = $this->resolveFileAbsolutePath($file); if ($absolutePath === null) { continue; } $entryName = $this->uniqueFileNameInFolder($folder, $file, $zip); $zip->addFile($absolutePath, $entryName); } } $zip->close(); @unlink($xlsxPath); return $zipPath; } /** * @param Collection $applications * @param Collection $trackTitles */ private function buildXlsxPath(Collection $applications, Collection $trackTitles): string { $headers = [ '项目编号', '状态', '项目名称', '负责人', '学校', '学历', '手机号', '邮箱', '组别', '企业名称', '赛道', '所在地', '报名渠道', '项目简介', '提交时间', '附件数量', ]; $rows = [$headers]; foreach ($applications as $app) { $location = $this->formatLocation($app); $channel = $app->signupChannel ? $app->signupChannel->channel_name.'('.$app->signupChannel->channel_code.')' : ($app->signup_channel_code ?: ''); $rows[] = [ ProjectCode::resolve($app), $app->status === 'submitted' ? '已提交' : '草稿', $app->project_name ?? '', $app->player_name ?? '', $app->school ?? '', $app->degree ?? '', $app->contact_mobile ?? '', $app->contact_email ?? '', $app->entry_group ?? '', $app->company_name ?? '', $trackTitles->get($app->track ?? '') ?? ($app->track ?? ''), $location, $channel, $app->intro ?? '', $app->submitted_at?->format('Y-m-d H:i:s') ?? '', $app->files->count(), ]; } $spreadsheet = new Spreadsheet; $sheet = $spreadsheet->getActiveSheet(); $sheet->fromArray($rows, null, 'A1'); $tmp = tempnam(sys_get_temp_dir(), 'app_export_xlsx_'); if ($tmp === false) { throw new \RuntimeException('无法创建 Excel 临时文件'); } $xlsxPath = $tmp.'.xlsx'; @unlink($tmp); (new Xlsx($spreadsheet))->save($xlsxPath); $spreadsheet->disconnectWorksheets(); return $xlsxPath; } /** * @param array $filters * @return Collection */ private function filteredApplications(Competition $competition, array $filters): Collection { return $this->buildFilteredQuery($competition, $filters) ->with(['files', 'signupChannel']) ->orderByDesc('submitted_at') ->orderByDesc('id') ->get(); } /** * @param array $filters */ public function buildFilteredQuery(Competition $competition, array $filters): Builder { $query = Application::query()->where('competition_id', $competition->id); $status = trim((string) ($filters['status'] ?? '')); if ($status !== '') { $query->where('status', $status); } $track = trim((string) ($filters['track'] ?? '')); if ($track !== '') { $query->where('track', $track); } $channelId = $filters['signup_channel_id'] ?? null; if ($channelId !== null && $channelId !== '') { $query->where('signup_channel_id', (int) $channelId); } $keyword = trim((string) ($filters['keyword'] ?? '')); if ($keyword !== '') { $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $keyword).'%'; $query->where(function ($q) use ($like): void { $q->where('player_name', 'like', $like) ->orWhere('project_name', 'like', $like) ->orWhere('school', 'like', $like) ->orWhere('contact_mobile', 'like', $like) ->orWhere('company_name', 'like', $like) ->orWhere('project_code', 'like', $like); }); } return $query; } private function resolveFileAbsolutePath(ApplicationFile $file): ?string { /** @var FilesystemAdapter $disk */ $disk = Storage::disk($file->disk); if (! $disk->exists($file->path)) { return null; } $absolutePath = $disk->path($file->path); if (! is_file($absolutePath)) { return null; } return $absolutePath; } private function formatLocation(Application $app): string { $country = trim((string) ($app->location_country ?? '')); if ($country === '海外' && ($app->oversea_country ?? '') !== '') { return '海外 / '.$app->oversea_country; } return implode(' / ', array_values(array_filter([ $app->location_country, $app->location_province, $app->location_city, ], fn ($v) => $v !== null && trim((string) $v) !== ''))); } /** * @param array $usedFolderNames */ private function uniqueFolderName(Application $application, array &$usedFolderNames): string { $base = $this->sanitizePathSegment( ProjectCode::resolve($application).($application->player_name ?? '未知') ); $folder = $base; $suffix = 2; while (isset($usedFolderNames[$folder])) { $folder = $base.'_'.$suffix; $suffix++; } return $folder; } private function uniqueFileNameInFolder(string $folder, ApplicationFile $file, ZipArchive $zip): string { $name = $file->clientDownloadName(); $candidate = $folder.'/'.$name; if ($zip->locateName($candidate) === false) { return $candidate; } $ext = pathinfo($name, PATHINFO_EXTENSION); $base = pathinfo($name, PATHINFO_FILENAME); $suffix = 2; while (true) { $next = $folder.'/'.($ext !== '' ? $base.'_'.$suffix.'.'.$ext : $base.'_'.$suffix); if ($zip->locateName($next) === false) { return $next; } $suffix++; } } private function sanitizePathSegment(string $value): string { $value = trim(str_replace(['\\', '/'], '_', $value)); $value = preg_replace('/[<>:"|?*]/u', '_', $value) ?? $value; $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? $value; return $value !== '' ? $value : '未命名'; } }