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.

197 lines
11 KiB

4 weeks ago
<?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;
3 weeks ago
$detail = $book->getActiveSheet()->setTitle('问题详细表');
3 weeks ago
$supplements = $book->createSheet()->setTitle('补充内容明细');
self::row($supplements,1,['答卷ID','答题人姓名','题目','选项','补充内容','提交时间']);
$sr=2;
4 weeks ago
$attachments = $book->createSheet()->setTitle('附件清单');
self::row($attachments, 1, ['答卷ID', '答题人姓名', '题目', '原文件名', '压缩包内路径', '状态']);
3 weeks ago
$dr = 3;
$ar = 2;
$columns = [];
4 weeks ago
$current = $survey->courseContentEvaluationAsks()->orderBy('sort')->orderBy('id')->get()->toArray();
foreach ($survey->courseContentEvaluationForms()->with('user')->lazyById(100) as $reply) {
3 weeks ago
$name = $reply->respondent['name'];
if (!is_scalar($name) || trim((string) $name) === '') $name = '未填写姓名';
self::row($detail, $dr, [$reply->id, $name]);
4 weeks ago
foreach (SurveySchema::replyQuestions($current, $reply->data, $reply->snapshot) as $q) {
3 weeks ago
if ($q['edit_input'] === 'section') continue;
4 weeks ago
$hidden = !empty($q['hidden']);
3 weeks ago
$extra='';
foreach (SurveySchema::supplements($q) as $note) {
$extra .= ($extra ? "\n" : '').($note['kind']==='option' ? $note['label'].':' : '').$note['text'];
self::row($supplements,$sr++,[$reply->id,$name,$q['name'],$note['label'],$note['text'],(string)$reply->created_at]);
4 weeks ago
}
3 weeks ago
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);
4 weeks ago
}
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 ? '已打包' : '文件缺失,未打包']);
}
}
3 weeks ago
$dr++;
4 weeks ago
}
3 weeks ago
// 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'];
4 weeks ago
}
3 weeks ago
self::headers($detail, $columns, $dr);
4 weeks ago
foreach ($book->getAllSheets() as $sheet) {
3 weeks ago
$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);
}
4 weeks ago
$sheet->getStyle($sheet->calculateWorksheetDimension())->getAlignment()->setWrapText(true)->setVertical('top');
3 weeks ago
$headerRange = 'A1:'.$sheet->getHighestColumn().$headerRows;
$sheet->getStyle($headerRange)->getFont()->setBold(true);
$sheet->getStyle($headerRange)->getAlignment()->setHorizontal('center')->setVertical('center');
self::fitRows($sheet, $headerRows);
4 weeks ago
}
(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();
}
}
3 weeks ago
private static function headers($sheet, array $columns, int $nextRow): void
4 weeks ago
{
3 weeks ago
// 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;
4 weeks ago
}
4 weeks ago
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);
}
}
}