'spring', 'label' => '春季'], ['value' => 'summer', 'label' => '夏季'], ['value' => 'autumn', 'label' => '秋季'], ['value' => 'winter', 'label' => '冬季'], ]; private const AUDIENCE_FALLBACK = [ ['value' => 'kindergarten', 'label' => '幼儿园'], ['value' => 'primary', 'label' => '小学'], ['value' => 'junior', 'label' => '初中'], ['value' => 'high', 'label' => '高中'], ['value' => 'all', 'label' => '全学段'], ]; /** * @param iterable $tours * @return array{path: string, filename: string, mime: string} */ public static function writeDownload(iterable $tours): array { $list = []; foreach ($tours as $tour) { if ($tour instanceof StudyTour) { $list[] = $tour; } } if ($list === []) { throw new \RuntimeException('未找到可导出的线路'); } if (count($list) === 1) { $tour = $list[0]; $path = self::writeDocxFile(self::payloadFromTour($tour)); return [ 'path' => $path, 'filename' => self::docxFilename($tour), 'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ]; } return [ 'path' => self::writeZipFile($list), 'filename' => '研学线路申报表-'.date('Ymd-His').'.zip', 'mime' => 'application/zip', ]; } /** * @param array $payload */ public static function writeDocxFile(array $payload): string { $phpWord = self::buildDocument($payload); $base = tempnam(sys_get_temp_dir(), 'st-docx-'); if ($base === false) { throw new \RuntimeException('无法创建临时文件'); } $path = $base.'.docx'; @unlink($base); $writer = IOFactory::createWriter($phpWord, 'Word2007'); $writer->save($path); return $path; } /** * @param array $tours */ public static function writeZipFile(array $tours): string { $base = tempnam(sys_get_temp_dir(), 'st-zip-'); if ($base === false) { throw new \RuntimeException('无法创建临时文件'); } $zipPath = $base.'.zip'; @unlink($base); $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException('无法创建压缩包'); } $usedNames = []; $tempFiles = []; try { foreach ($tours as $tour) { $payload = self::payloadFromTour($tour); $docx = self::writeDocxFile($payload); $tempFiles[] = $docx; $entry = self::uniqueZipEntryName(self::docxFilename($tour), $usedNames); $usedNames[$entry] = true; $zip->addFile($docx, $entry); } $zip->close(); } catch (\Throwable $e) { $zip->close(); foreach ($tempFiles as $file) { if (is_string($file) && is_file($file)) { @unlink($file); } } if (is_file($zipPath)) { @unlink($zipPath); } throw $e; } foreach ($tempFiles as $file) { if (is_string($file) && is_file($file)) { @unlink($file); } } return $zipPath; } /** * @return array */ public static function payloadFromTour(StudyTour $tour): array { return [ 'name' => (string) ($tour->name ?? ''), 'org_name' => (string) ($tour->org_name ?? ''), 'seasons' => array_values($tour->seasons ?? []), 'venue_names' => self::venueNames($tour), 'suitable_count' => (string) ($tour->suitable_count ?? ''), 'suitable_audience' => (string) ($tour->suitable_audience ?? ''), 'duration' => (string) ($tour->duration ?? ''), 'contact_person' => (string) ($tour->contact_person ?? ''), 'contact_phones' => (string) ($tour->contact_phones ?? ''), 'cover_image_path' => self::resolvePublicImagePath($tour->cover_image), 'intro_html' => (string) ($tour->intro_html ?? ''), 'route_plans' => array_values($tour->route_plans ?? []), 'courses' => array_values($tour->courses ?? []), 'fee_html' => (string) ($tour->fee_html ?? ''), 'implementation_html' => (string) ($tour->implementation_html ?? ''), 'season_options' => self::dictOptions('study_tour_season', self::SEASON_FALLBACK), 'audience_options' => self::dictOptions('study_tour_grade_level', self::AUDIENCE_FALLBACK), ]; } /** * @param array $payload */ public static function buildDocument(array $payload): PhpWord { $phpWord = new PhpWord(); $phpWord->setDefaultFontName('宋体'); $phpWord->setDefaultFontSize(11); $phpWord->addTitleStyle(1, ['name' => '黑体', 'size' => 16, 'bold' => true], [ 'spaceBefore' => 360, 'spaceAfter' => 160, ]); $section = $phpWord->addSection([ 'paperSize' => 'A4', 'marginTop' => 1134, 'marginBottom' => 1134, 'marginLeft' => 1134, 'marginRight' => 1134, ]); $section->addText('研学线路申报表', ['name' => '黑体', 'size' => 18, 'bold' => true], [ 'alignment' => Jc::CENTER, 'spaceAfter' => 240, ]); $section->addTitle('一、线路基本情况', 1); self::addBasicTable($section, $payload); self::addCoverImage($section, $payload); $section->addTitle('二、线路简介', 1); self::addHtmlAsParagraphs($section, (string) ($payload['intro_html'] ?? '')); $section->addTitle('三、线路规划', 1); self::addRouteTable($section, is_array($payload['route_plans'] ?? null) ? $payload['route_plans'] : []); $section->addTitle('四、研学课程', 1); self::addCourseTable($section, is_array($payload['courses'] ?? null) ? $payload['courses'] : []); $section->addTitle('五、线路收费标准', 1); self::addHtmlAsParagraphs($section, (string) ($payload['fee_html'] ?? '')); $section->addTitle('六、线路计划实施情况', 1); self::addHtmlAsParagraphs($section, (string) ($payload['implementation_html'] ?? '')); return $phpWord; } public static function htmlToPlainText(string $html): string { $html = trim($html); if ($html === '') { return ''; } $html = preg_replace('/<(br|BR)\s*\/?>/u', "\n", $html) ?? $html; $html = preg_replace('/<\/(p|div|li|h[1-6]|tr)[^>]*>/iu', "\n", $html) ?? $html; $html = preg_replace('/]*>/iu', "· ", $html) ?? $html; $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return StudyTourPayload::compactMultilineText($text); } public static function sanitizeFilename(string $name): string { $name = StudyTourPayload::compactText($name); $name = preg_replace('/[\\\\\/:\*\?"<>\|\r\n]+/u', '_', $name) ?? $name; $name = trim($name, ' ._'); if ($name === '') { $name = '研学线路'; } return mb_substr($name, 0, 80); } public static function resolvePublicImagePath(?string $raw): ?string { $raw = trim((string) $raw); if ($raw === '') { return null; } $path = $raw; if (preg_match('#^https?://#i', $raw)) { $path = (string) (parse_url($raw, PHP_URL_PATH) ?: ''); } $path = rawurldecode($path); $path = str_replace('\\', '/', $path); if (str_starts_with($path, '/storage/')) { $rel = ltrim(substr($path, strlen('/storage/')), '/'); } elseif (str_starts_with($path, 'storage/')) { $rel = ltrim(substr($path, strlen('storage/')), '/'); } else { $rel = ltrim($path, '/'); } if ($rel === '' || str_contains($rel, '..')) { return null; } $full = storage_path('app/public/'.$rel); $real = realpath($full); $root = realpath(storage_path('app/public')); if ($real === false || $root === false || ! is_file($real)) { return null; } if ($real !== $root && ! str_starts_with($real, $root.DIRECTORY_SEPARATOR)) { return null; } return $real; } private static function docxFilename(StudyTour $tour): string { $stem = self::sanitizeFilename((string) ($tour->name ?: '研学线路-'.$tour->id)); return $stem.'.docx'; } /** * @param array $usedNames */ private static function uniqueZipEntryName(string $filename, array $usedNames): string { if (! isset($usedNames[$filename])) { return $filename; } $stem = preg_replace('/\.docx$/i', '', $filename) ?? $filename; $i = 2; do { $candidate = $stem.'-'.$i.'.docx'; $i++; } while (isset($usedNames[$candidate])); return $candidate; } /** * @return array */ private static function venueNames(StudyTour $tour): array { $items = StudyTourPayload::venueItemsForRecord($tour); $systemIds = collect($items) ->filter(fn ($item) => ($item['type'] ?? '') === 'system') ->map(fn ($item) => (int) ($item['venue_id'] ?? 0)) ->filter(fn ($id) => $id > 0) ->values() ->all(); $venueMap = $systemIds === [] ? collect() : Venue::query()->whereIn('id', $systemIds)->get(['id', 'name'])->keyBy('id'); $names = []; foreach ($items as $item) { if (($item['type'] ?? '') === 'system') { $id = (int) ($item['venue_id'] ?? 0); $name = trim((string) ($venueMap->get($id)?->name ?? '')); if ($name !== '') { $names[] = $name; } continue; } $name = StudyTourPayload::compactText((string) ($item['name'] ?? '')); if ($name !== '') { $names[] = $name; } } return $names; } /** * @param array $fallback * @return array */ private static function dictOptions(string $dictType, array $fallback): array { try { $options = DictItem::activeOptions($dictType); if ($options !== []) { return $options; } } catch (\Throwable) { // tests or missing table: use fallback } return $fallback; } /** * @param array $payload */ private static function addBasicTable($section, array $payload): void { $table = $section->addTable(self::tableStyle()); $rows = [ ['组织单位名称', (string) ($payload['org_name'] ?? '')], ['线路名称', (string) ($payload['name'] ?? '')], ['对应季节(可多选)', self::checkboxLine( is_array($payload['season_options'] ?? null) ? $payload['season_options'] : self::SEASON_FALLBACK, is_array($payload['seasons'] ?? null) ? $payload['seasons'] : [] )], ['线路点位', implode('、', array_values($payload['venue_names'] ?? []))], ['适宜人数', (string) ($payload['suitable_count'] ?? '')], ['适配学段(可多选)', self::audienceLine($payload)], ['研学时长', (string) ($payload['duration'] ?? '')], ['线路联络人', (string) ($payload['contact_person'] ?? '')], ['咨询电话', (string) ($payload['contact_phones'] ?? '')], ]; foreach ($rows as [$label, $value]) { $table->addRow(); self::addLabelCell($table, $label); self::addValueCell($table, $value); } } /** * @param array $payload */ private static function addCoverImage($section, array $payload): void { $path = $payload['cover_image_path'] ?? null; $section->addText('封面图', ['bold' => true], ['spaceBefore' => 200, 'spaceAfter' => 80]); if (! is_string($path) || $path === '' || ! is_file($path)) { $section->addText('(未设置封面图)', ['color' => '86909C'], ['spaceAfter' => 200]); return; } $size = self::coverImageSize($path); try { $section->addImage($path, [ 'width' => $size['width'], 'height' => $size['height'], 'wrappingStyle' => 'inline', ]); } catch (\Throwable) { $section->addText('(封面图无法嵌入文档)', ['color' => '86909C']); } $section->addTextBreak(1); } /** * @return array{width: int, height: int} */ private static function coverImageSize(string $path): array { $maxW = 360; $maxH = 240; $info = @getimagesize($path); $w = is_array($info) ? (int) ($info[0] ?? 0) : 0; $h = is_array($info) ? (int) ($info[1] ?? 0) : 0; if ($w <= 0 || $h <= 0) { return ['width' => $maxW, 'height' => 180]; } $scale = min($maxW / $w, $maxH / $h, 1); return [ 'width' => (int) max(1, round($w * $scale)), 'height' => (int) max(1, round($h * $scale)), ]; } private static function addHtmlAsParagraphs($section, string $html): void { $text = self::htmlToPlainText($html); if ($text === '') { $section->addText('(暂无)', ['color' => '86909C'], ['spaceAfter' => 160]); return; } foreach (preg_split('/\R/u', $text) ?: [] as $line) { $line = trim($line); if ($line === '') { continue; } $section->addText($line, [], ['spaceAfter' => 80]); } } /** * @param array $plans */ private static function addRouteTable($section, array $plans): void { $table = $section->addTable(self::tableStyle()); $table->addRow(); foreach (['日期', '时间', '行程安排', '地点'] as $header) { self::addHeaderCell($table, $header); } $hasRow = false; foreach ($plans as $plan) { if (! is_array($plan)) { continue; } $dateLabel = StudyTourPayload::compactText((string) ($plan['date_label'] ?? '')); $items = is_array($plan['items'] ?? null) ? $plan['items'] : []; if ($items === []) { $table->addRow(); self::addValueCell($table, $dateLabel, 1800); self::addValueCell($table, '', 1400); self::addValueCell($table, '', 3600); self::addValueCell($table, '', 2200); $hasRow = true; continue; } foreach ($items as $item) { if (! is_array($item)) { continue; } $table->addRow(); self::addValueCell($table, $dateLabel, 1800); self::addValueCell($table, (string) ($item['time'] ?? ''), 1400); self::addValueCell($table, (string) ($item['activity'] ?? ''), 3600); self::addValueCell($table, (string) ($item['location'] ?? ''), 2200); $hasRow = true; } } if (! $hasRow) { $table->addRow(); self::addValueCell($table, '', 1800); self::addValueCell($table, '', 1400); self::addValueCell($table, '', 3600); self::addValueCell($table, '', 2200); } } /** * @param array $courses */ private static function addCourseTable($section, array $courses): void { $table = $section->addTable(self::tableStyle()); $table->addRow(); foreach (['序号', '课程名称', '课程内容'] as $header) { self::addHeaderCell($table, $header); } $rows = []; foreach ($courses as $idx => $course) { if (! is_array($course)) { continue; } $rows[] = [ (string) ((int) ($course['sort'] ?? ($idx + 1))), (string) ($course['name'] ?? ''), (string) ($course['content'] ?? ''), ]; } if ($rows === []) { $rows[] = ['', '', '']; } foreach ($rows as [$sort, $name, $content]) { $table->addRow(); self::addValueCell($table, $sort, 900); self::addValueCell($table, $name, 2800); self::addValueCell($table, $content, 5300); } } /** * @return array */ private static function tableStyle(): array { return [ 'borderSize' => 6, 'borderColor' => '000000', 'cellMargin' => 60, 'width' => 5000, 'unit' => TblWidth::PERCENT, ]; } private static function addLabelCell($table, string $text): void { $cell = $table->addCell(2200, [ 'bgColor' => 'F2F3F5', 'valign' => VerticalJc::CENTER, ]); $cell->addText($text, ['bold' => true]); } private static function addHeaderCell($table, string $text): void { $cell = $table->addCell(null, [ 'bgColor' => 'F2F3F5', 'valign' => VerticalJc::CENTER, ]); $cell->addText($text, ['bold' => true], ['alignment' => Jc::CENTER]); } private static function addValueCell($table, string $text, ?int $width = 6800): void { $cell = $table->addCell($width ?? 6800, ['valign' => VerticalJc::CENTER]); $text = str_replace(["\r\n", "\r"], "\n", $text); $lines = preg_split('/\n/u', $text) ?: [$text]; $wrote = false; foreach ($lines as $line) { $line = trim((string) $line); if ($line === '') { continue; } $cell->addText($line); $wrote = true; } if (! $wrote) { $cell->addText(''); } } /** * @param array $options * @param array $selected */ private static function checkboxLine(array $options, array $selected): string { $set = []; foreach ($selected as $value) { $set[trim((string) $value)] = true; } $parts = []; foreach ($options as $opt) { $value = (string) ($opt['value'] ?? ''); $label = (string) ($opt['label'] ?? $value); if ($label === '') { continue; } $mark = isset($set[$value]) ? '☑' : '☐'; $parts[] = $mark.$label; } return implode(' ', $parts); } /** * @param array $payload */ private static function audienceLine(array $payload): string { $raw = StudyTourPayload::compactText((string) ($payload['suitable_audience'] ?? '')); $options = is_array($payload['audience_options'] ?? null) ? $payload['audience_options'] : self::AUDIENCE_FALLBACK; if ($raw === '') { return self::checkboxLine($options, []); } $parts = preg_split('/[,,、;;\s]+/u', $raw) ?: []; $parts = array_values(array_filter(array_map('trim', $parts), fn ($p) => $p !== '')); $valueSet = []; foreach ($options as $opt) { $valueSet[(string) $opt['value']] = true; $valueSet[(string) $opt['label']] = (string) $opt['value']; } $selected = []; $unmatched = []; foreach ($parts as $part) { if (isset($valueSet[$part]) && $valueSet[$part] === true) { $selected[] = $part; continue; } if (isset($valueSet[$part]) && is_string($valueSet[$part])) { $selected[] = $valueSet[$part]; continue; } $unmatched[] = $part; } if ($selected === [] && $unmatched !== []) { return $raw; } $line = self::checkboxLine($options, $selected); if ($unmatched !== []) { $extra = implode(',', $unmatched); $line = $line === '' ? $extra : $line.' '.$extra; } return $line; } }