From 1bba4d173c908b781984984b0d07e1ffe526ddc3 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Wed, 9 Sep 2026 10:25:55 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BA=BF=E8=B7=AF=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/StudyTourController.php | 48 ++ app/Support/AdminAuditOperationDescriber.php | 6 + app/Support/StudyTourDeclarationExporter.php | 649 ++++++++++++++++++ config/cors.php | 2 +- routes/api.php | 1 + .../Unit/StudyTourDeclarationExporterTest.php | 119 ++++ 6 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 app/Support/StudyTourDeclarationExporter.php create mode 100644 tests/Unit/StudyTourDeclarationExporterTest.php diff --git a/app/Http/Controllers/Api/StudyTourController.php b/app/Http/Controllers/Api/StudyTourController.php index 279a91f..0182e24 100644 --- a/app/Http/Controllers/Api/StudyTourController.php +++ b/app/Http/Controllers/Api/StudyTourController.php @@ -5,10 +5,13 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\StudyTour; use App\Support\SecurePrivateImportUpload; +use App\Support\StudyTourDeclarationExporter; use App\Support\StudyTourDeclarationParser; use App\Support\StudyTourPayload; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\HeaderUtils; class StudyTourController extends Controller { @@ -61,6 +64,51 @@ class StudyTourController extends Controller return response()->json(['message' => '删除成功']); } + public function export(Request $request): BinaryFileResponse|JsonResponse + { + $this->ensureSuperAdmin($request); + $data = $request->validate([ + 'ids' => ['required', 'array', 'min:1', 'max:'.StudyTourDeclarationExporter::MAX_BATCH], + 'ids.*' => ['integer', 'min:1'], + ]); + $ids = array_values(array_unique(array_map('intval', $data['ids']))); + $order = array_flip($ids); + $tours = StudyTour::query() + ->whereIn('id', $ids) + ->get() + ->sortBy(fn (StudyTour $row) => $order[$row->id] ?? 9999) + ->values(); + + if ($tours->isEmpty()) { + return response()->json(['message' => '未找到可导出的线路'], 422); + } + + try { + $download = StudyTourDeclarationExporter::writeDownload($tours); + } catch (\Throwable $e) { + return response()->json(['message' => '导出失败:'.$e->getMessage()], 422); + } + + $ascii = str_ends_with(strtolower($download['filename']), '.zip') + ? 'study-tours.zip' + : 'study-tour.docx'; + $response = response()->download( + $download['path'], + $ascii, + ['Content-Type' => $download['mime']] + )->deleteFileAfterSend(true); + $response->headers->set( + 'Content-Disposition', + HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_ATTACHMENT, + $download['filename'], + $ascii + ) + ); + + return $response; + } + public function parseDoc(Request $request): JsonResponse { $request->validate([ diff --git a/app/Support/AdminAuditOperationDescriber.php b/app/Support/AdminAuditOperationDescriber.php index b0e4a34..dbf64a5 100644 --- a/app/Support/AdminAuditOperationDescriber.php +++ b/app/Support/AdminAuditOperationDescriber.php @@ -213,6 +213,12 @@ final class AdminAuditOperationDescriber return $t !== null ? '新增研学路线「'.$t.'」'.$fail : '新增研学路线'.$fail; } + if ($path === 'study-tours/export' && $method === 'POST') { + $ids = $payload['ids'] ?? null; + $n = is_array($ids) ? count($ids) : 0; + + return $n > 0 ? '导出研学路线申报表('.$n.'条)'.$fail : '导出研学路线申报表'.$fail; + } if (preg_match('#^study-tours/(\d+)$#', $path, $m)) { $id = $m[1]; if ($method === 'PUT' || $method === 'PATCH') { diff --git a/app/Support/StudyTourDeclarationExporter.php b/app/Support/StudyTourDeclarationExporter.php new file mode 100644 index 0000000..462992e --- /dev/null +++ b/app/Support/StudyTourDeclarationExporter.php @@ -0,0 +1,649 @@ + '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; + } +} diff --git a/config/cors.php b/config/cors.php index b495769..0671f44 100644 --- a/config/cors.php +++ b/config/cors.php @@ -28,7 +28,7 @@ return [ 'allowed_headers' => ['*'], - 'exposed_headers' => [], + 'exposed_headers' => ['Content-Disposition'], 'max_age' => 0, diff --git a/routes/api.php b/routes/api.php index 0d53dd7..5935846 100644 --- a/routes/api.php +++ b/routes/api.php @@ -131,6 +131,7 @@ Route::middleware(['auth:sanctum', 'audit.log'])->group(function () { Route::get('/map/reverse-geocode', [MapController::class, 'reverseGeocode']); Route::get('/study-tours', [StudyTourController::class, 'index']); Route::post('/study-tours/parse-doc', [StudyTourController::class, 'parseDoc']); + Route::post('/study-tours/export', [StudyTourController::class, 'export']); Route::post('/study-tours', [StudyTourController::class, 'store']); Route::put('/study-tours/{studyTour}', [StudyTourController::class, 'update']); Route::delete('/study-tours/{studyTour}', [StudyTourController::class, 'destroy']); diff --git a/tests/Unit/StudyTourDeclarationExporterTest.php b/tests/Unit/StudyTourDeclarationExporterTest.php new file mode 100644 index 0000000..e686fb2 --- /dev/null +++ b/tests/Unit/StudyTourDeclarationExporterTest.php @@ -0,0 +1,119 @@ +第一段

第二段
续行

  • 要点A
  • 要点B
'; + $text = StudyTourDeclarationExporter::htmlToPlainText($html); + + $this->assertStringContainsString("第一段", $text); + $this->assertStringContainsString("第二段", $text); + $this->assertStringContainsString("续行", $text); + $this->assertStringContainsString("要点A", $text); + $this->assertStringContainsString("要点B", $text); + } + + public function test_sanitize_filename_strips_illegal_chars(): void + { + $this->assertSame('线路A_B', StudyTourDeclarationExporter::sanitizeFilename('线路A/B')); + $this->assertSame('研学线路', StudyTourDeclarationExporter::sanitizeFilename(' ')); + } + + public function test_docx_contains_declaration_sections_and_cover_image(): void + { + $png = sys_get_temp_dir().'/st-cover-test.png'; + file_put_contents($png, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' + )); + + $path = StudyTourDeclarationExporter::writeDocxFile([ + 'name' => '太湖蚕桑研学', + 'org_name' => '苏州市科协', + 'seasons' => ['spring', 'autumn'], + 'venue_names' => ['苏州丝绸博物馆', '自定义营地'], + 'suitable_count' => '30人', + 'suitable_audience' => 'primary,junior', + 'duration' => '2天1晚', + 'contact_person' => '张老师', + 'contact_phones' => '13800000000', + 'cover_image_path' => $png, + 'intro_html' => '

走进蚕桑文化。

', + 'route_plans' => [[ + 'date_label' => '第一天', + 'items' => [[ + 'time' => '09:00', + 'activity' => '开营仪式', + 'location' => '活动中心', + ]], + ]], + 'courses' => [[ + 'sort' => 1, + 'name' => '缫丝体验', + 'content' => '了解传统缫丝工艺', + ]], + 'fee_html' => '

680元/人

', + 'implementation_html' => '

2026年7月1日第1场

', + 'season_options' => [ + ['value' => 'spring', 'label' => '春季'], + ['value' => 'summer', 'label' => '夏季'], + ['value' => 'autumn', 'label' => '秋季'], + ['value' => 'winter', 'label' => '冬季'], + ], + 'audience_options' => [ + ['value' => 'kindergarten', 'label' => '幼儿园'], + ['value' => 'primary', 'label' => '小学'], + ['value' => 'junior', 'label' => '初中'], + ['value' => 'high', 'label' => '高中'], + ['value' => 'all', 'label' => '全学段'], + ], + ]); + + $this->assertFileExists($path); + + $zip = new ZipArchive(); + $this->assertTrue($zip->open($path) === true); + $xml = (string) $zip->getFromName('word/document.xml'); + $mediaCount = 0; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = (string) $zip->getNameIndex($i); + if (str_starts_with($name, 'word/media/')) { + $mediaCount++; + } + } + $zip->close(); + + foreach ([ + '一、线路基本情况', + '二、线路简介', + '三、线路规划', + '四、研学课程', + '五、线路收费标准', + '六、线路计划实施情况', + '太湖蚕桑研学', + '苏州市科协', + '苏州丝绸博物馆', + '走进蚕桑文化', + '开营仪式', + '缫丝体验', + '680元/人', + '封面图', + ] as $needle) { + $this->assertStringContainsString($needle, $xml, "missing {$needle}"); + } + $this->assertGreaterThan(0, $mediaCount, 'cover image should be embedded'); + + $phpWord = IOFactory::load($path); + $this->assertNotEmpty($phpWord->getSections()); + + @unlink($path); + @unlink($png); + } +}