线路导出

master
lion 1 week ago
parent a468a4135c
commit 1bba4d173c

@ -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([

@ -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') {

@ -0,0 +1,649 @@
<?php
namespace App\Support;
use App\Models\DictItem;
use App\Models\StudyTour;
use App\Models\Venue;
use PhpOffice\PhpWord\IOFactory;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\SimpleType\Jc;
use PhpOffice\PhpWord\SimpleType\TblWidth;
use PhpOffice\PhpWord\SimpleType\VerticalJc;
use ZipArchive;
class StudyTourDeclarationExporter
{
public const MAX_BATCH = 50;
private const SEASON_FALLBACK = [
['value' => '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<int, StudyTour> $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<string, mixed> $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<int, StudyTour> $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<string, mixed>
*/
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<string, mixed> $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('/<li[^>]*>/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<string, true> $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<int, string>
*/
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<int, array{value: string, label: string}> $fallback
* @return array<int, array{value: string, label: string}>
*/
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<string, mixed> $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<string, mixed> $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<int, mixed> $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<int, mixed> $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<string, mixed>
*/
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<int, array{value: string, label: string}> $options
* @param array<int, mixed> $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<string, mixed> $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;
}
}

@ -28,7 +28,7 @@ return [
'allowed_headers' => ['*'],
'exposed_headers' => [],
'exposed_headers' => ['Content-Disposition'],
'max_age' => 0,

@ -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']);

@ -0,0 +1,119 @@
<?php
namespace Tests\Unit;
use App\Support\StudyTourDeclarationExporter;
use PhpOffice\PhpWord\IOFactory;
use Tests\TestCase;
use ZipArchive;
class StudyTourDeclarationExporterTest extends TestCase
{
public function test_html_to_plain_text_keeps_paragraphs(): void
{
$html = '<p>第一段</p><p>第二段<br/>续行</p><ul><li>要点A</li><li>要点B</li></ul>';
$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' => 'primaryjunior',
'duration' => '2天1晚',
'contact_person' => '张老师',
'contact_phones' => '13800000000',
'cover_image_path' => $png,
'intro_html' => '<p>走进蚕桑文化。</p>',
'route_plans' => [[
'date_label' => '第一天',
'items' => [[
'time' => '09:00',
'activity' => '开营仪式',
'location' => '活动中心',
]],
]],
'courses' => [[
'sort' => 1,
'name' => '缫丝体验',
'content' => '了解传统缫丝工艺',
]],
'fee_html' => '<p>680元/人</p>',
'implementation_html' => '<p>2026年7月1日第1场</p>',
'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);
}
}
Loading…
Cancel
Save