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 @@
+第一段
第二段
续行
';
+ $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);
+ }
+}