You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
szkp-map-service/app/Support/StudyTourDeclarationExporte...

650 lines
21 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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;
}
}