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.

194 lines
11 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\Services\Survey;
use App\Models\CourseContentEvaluation;
use App\Models\Upload;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use ZipArchive;
class SurveyExport
{
private static function safeName($name): string
{
return mb_substr(trim(preg_replace('/[\\\\\\/<>:"|?*\x00-\x1f]/u', '_', (string) $name), '. '), 0, 80) ?: '未命名';
}
public static function build(CourseContentEvaluation $survey): string
{
$dir = storage_path('app/survey-exports');
if (!is_dir($dir)) mkdir($dir, 0700, true);
$base = $dir.'/'.bin2hex(random_bytes(16));
$book = new Spreadsheet();
$zip = new ZipArchive();
$opened = false;
try {
if ($zip->open($base.'.zip', ZipArchive::CREATE | ZipArchive::EXCL) !== true) throw new \RuntimeException('无法创建导出文件');
$opened = true;
$detail = $book->getActiveSheet()->setTitle('问题详细表');
$attachments = $book->createSheet()->setTitle('附件清单');
self::row($attachments, 1, ['答卷ID', '答题人姓名', '题目', '原文件名', '压缩包内路径', '状态']);
$dr = 3;
$ar = 2;
$columns = [];
$current = $survey->courseContentEvaluationAsks()->orderBy('sort')->orderBy('id')->get()->toArray();
foreach ($survey->courseContentEvaluationForms()->with('user')->lazyById(100) as $reply) {
$name = $reply->respondent['name'];
if (!is_scalar($name) || trim((string) $name) === '') $name = '未填写姓名';
self::row($detail, $dr, [$reply->id, $name]);
foreach (SurveySchema::replyQuestions($current, $reply->data, $reply->snapshot) as $q) {
if ($q['edit_input'] === 'section') continue;
$hidden = !empty($q['hidden']);
$extra = $hidden ? '' : ($q['other_text'] ?? '');
foreach ($q['select_item'] ?? [] as $opt) {
$note = $q['option_texts'][$opt['key']] ?? '';
if (!$hidden && $note !== '') $extra .= ($extra ? "\n" : '').$opt['value'].':'.$note;
}
foreach (self::questionFields($q) as [$key, $label, $field]) {
if (!isset($columns[$key])) $columns[$key] = ['column' => count($columns) + 3, 'label' => $label, 'title' => $q['name'], 'group' => (string) ($q['id'] ?? $q['field']), 'dimension' => $q['edit_input'] === 'multi_dimension' ? $field['name'] : null, 'type' => $field['edit_input'] ?? 'text'];
$answer = $hidden || SurveySchema::blank($field['value'] ?? null) ? '' : ($field['display_value'] ?? '');
if ($q['edit_input'] === 'multi_dimension') {
$value = $q['value'][$field['field']] ?? null;
$answer = $hidden || SurveySchema::blank($value) ? '' : ($field['display_value'] ?? '');
}
if ($extra !== '') $answer .= ($answer !== '' ? "\n" : '').'补充:'.$extra;
$detail->setCellValueExplicit(Coordinate::stringFromColumnIndex($columns[$key]['column']).$dr, $answer, DataType::TYPE_STRING);
}
if ($hidden || $q['edit_input'] !== 'file') continue;
foreach ($q['value'] ?? [] as $item) {
$file = Upload::find($item['id'] ?? null);
$original = $file->original_name ?? $item['original_name'] ?? '附件';
$entry = '附件/'.$reply->id.'_'.self::safeName($name).'/'.$q['id'].'_'.self::safeName($q['name']).'/'.($item['id'] ?? '0').'_'.self::safeName($original);
$valid = $file && $file->folder === 'storage/files' && basename($file->name) === $file->name && Storage::disk('public')->exists('files/'.$file->name);
if ($valid && !$zip->addFile(Storage::disk('public')->path('files/'.$file->name), $entry)) throw new \RuntimeException('附件打包失败');
self::row($attachments, $ar++, [$reply->id, $name, $q['name'], $original, $valid ? $entry : '', $valid ? '已打包' : '文件缺失,未打包']);
}
}
$dr++;
}
// An unanswered survey still exports its question headings.
if (!$columns && $dr === 3) foreach ($current as $q) {
if ($q['edit_input'] === 'section') continue;
foreach (self::questionFields($q) as [$key, $label, $field]) $columns[$key] = ['column' => count($columns) + 3, 'label' => $label, 'title' => $q['name'], 'group' => (string) ($q['id'] ?? $q['field']), 'dimension' => $q['edit_input'] === 'multi_dimension' ? $field['name'] : null, 'type' => $field['edit_input'] ?? 'text'];
}
self::headers($detail, $columns, $dr);
foreach ($book->getAllSheets() as $sheet) {
$headerRows = $sheet === $detail ? 2 : 1;
if ($sheet === $detail) $sheet->freezePane('C3');
else {
$sheet->freezePane('C2');
$sheet->setAutoFilter($sheet->calculateWorksheetDimension());
foreach ([12, 18, 36, 42, 64, 26] as $index => $width) $sheet->getColumnDimensionByColumn($index + 1)->setWidth($width);
}
$sheet->getStyle($sheet->calculateWorksheetDimension())->getAlignment()->setWrapText(true)->setVertical('top');
$headerRange = 'A1:'.$sheet->getHighestColumn().$headerRows;
$sheet->getStyle($headerRange)->getFont()->setBold(true);
$sheet->getStyle($headerRange)->getAlignment()->setHorizontal('center')->setVertical('center');
self::fitRows($sheet, $headerRows);
}
(new Xlsx($book))->save($base.'.xlsx');
$zip->addFile($base.'.xlsx', '问卷答卷明细.xlsx');
if (!$zip->close()) throw new \RuntimeException('导出压缩包生成失败');
$opened = false;
return $base.'.zip';
} catch (\Throwable $e) {
if ($opened) $zip->close();
@unlink($base.'.zip');
throw $e;
} finally {
@unlink($base.'.xlsx');
$book->disconnectWorksheets();
}
}
private static function headers($sheet, array $columns, int $nextRow): void
{
// Group by stable question ID; equal titles and snapshots with extra dimensions stay distinct.
$groups = [];
foreach ($columns as $column) $groups[$column['group']][] = $column;
$ordered = [];
foreach ($groups as $group) foreach ($group as $column) $ordered[] = $column;
for ($row = 3; $row < $nextRow; $row++) {
$values = [];
foreach ($ordered as $column) $values[] = $sheet->getCell(Coordinate::stringFromColumnIndex($column['column']).$row)->getValue();
foreach ($values as $index => $value) $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($index + 3).$row, (string) $value, DataType::TYPE_STRING);
}
self::row($sheet, 1, ['答卷ID', '答题人姓名']);
$sheet->mergeCells('A1:A2');
$sheet->mergeCells('B1:B2');
$sheet->getColumnDimension('A')->setWidth(12);
$sheet->getColumnDimension('B')->setWidth(18);
$next = 3;
foreach ($groups as $group) {
$start = $next;
foreach ($group as $column) {
$letter = Coordinate::stringFromColumnIndex($next++);
$widths = ['number' => 18, 'date' => 20, 'datetime' => 25, 'file' => 42, 'checkbox' => 38, 'textarea' => 52];
$sheet->getColumnDimension($letter)->setWidth($widths[$column['type']] ?? 32);
if ($column['dimension'] === null) {
$sheet->setCellValueExplicit($letter.'1', $column['title'], DataType::TYPE_STRING);
$sheet->mergeCells($letter.'1:'.$letter.'2');
} else $sheet->setCellValueExplicit($letter.'2', $column['dimension'], DataType::TYPE_STRING);
}
if ($group[0]['dimension'] !== null) {
$first = Coordinate::stringFromColumnIndex($start);
$last = Coordinate::stringFromColumnIndex($next - 1);
$sheet->setCellValueExplicit($first.'1', $group[0]['title'], DataType::TYPE_STRING);
if ($next - $start > 1) $sheet->mergeCells($first.'1:'.$last.'1');
}
}
}
private static function fitRows($sheet, int $headerRows): void
{
$heights = [];
$merged = [];
foreach ($sheet->getMergeCells() as $range) {
[[$left, $top], [$right, $bottom]] = Coordinate::rangeBoundaries($range);
for ($row = $top; $row <= $bottom; $row++) for ($col = $left; $col <= $right; $col++) $merged[$row][$col] = [$left, $top, $right, $bottom];
}
$lastColumn = Coordinate::columnIndexFromString($sheet->getHighestColumn());
for ($row = 1; $row <= $sheet->getHighestRow(); $row++) {
$heights[$row] = max($heights[$row] ?? 0, $row <= $headerRows ? 34 : 30);
for ($col = 1; $col <= $lastColumn; $col++) {
$text = (string) $sheet->getCell(Coordinate::stringFromColumnIndex($col).$row)->getValue();
if ($text === '') continue;
[$left, $top, $right, $bottom] = $merged[$row][$col] ?? [$col, $row, $col, $row];
if ($col !== $left || $row !== $top) continue;
$width = 0;
for ($c = $left; $c <= $right; $c++) $width += $sheet->getColumnDimensionByColumn($c)->getWidth();
// Excel widths use roughly half-width characters; CJK characters count twice.
$charactersPerLine = max(1, $width - 3);
$lines = 0;
foreach (preg_split('/\r\n|\r|\n/', $text) as $line) $lines += max(1, (int) ceil(mb_strwidth($line, 'UTF-8') / $charactersPerLine));
$height = min(409, max(30, ceil(($lines * 16 + 12) / ($bottom - $top + 1))));
for ($r = $top; $r <= $bottom; $r++) $heights[$r] = max($heights[$r] ?? 0, $height);
}
}
foreach ($heights as $row => $height) $sheet->getRowDimension($row)->setRowHeight($height);
}
private static function questionFields(array $q): array
{
$key = (string) ($q['id'] ?? $q['field']);
if ($q['edit_input'] !== 'multi_dimension') return [[$key, $q['name'], $q]];
$fields = [];
foreach ($q['dimensions'] ?? [] as $dim) {
$fields[] = [$key.'/'.($dim['field'] ?? $dim['name']), $q['name'].' / '.$dim['name'], $dim];
}
return $fields;
}
private static function row($sheet, int $row, array $values): void
{
foreach ($values as $index => $value) {
// User input is always text, never an Excel formula.
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($index + 1).$row, (string) $value, DataType::TYPE_STRING);
}
}
}