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.

139 lines
8.5 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 const TYPES = ['radio' => '单选/评分', 'checkbox' => '多选', 'text' => '短文本', 'textarea' => '长文本', 'number' => '数字', 'date' => '日期', 'datetime' => '日期时间', 'file' => '文件上传'];
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;
$summary = $book->getActiveSheet()->setTitle('答卷列表');
$detail = $book->createSheet()->setTitle('问题详细表');
$attachments = $book->createSheet()->setTitle('附件清单');
self::row($summary, 1, ['答卷ID', '问卷名称', '答题人姓名', '学号', '公司', '职务', '首次提交时间', '最近提交时间']);
self::row($detail, 1, ['答卷ID', '答题人姓名', '章节', '题号', '题目', '子题/维度', '答案', '补充说明', '题型', '题目说明', '所有选项']);
self::row($attachments, 1, ['答卷ID', '答题人姓名', '题目', '原文件名', '压缩包内路径', '状态']);
$sr = $dr = $ar = 2;
$hasSections = $hasDimensions = false;
$replyRanges = $sectionRanges = $questionRanges = $plainRows = [];
$current = $survey->courseContentEvaluationAsks()->orderBy('sort')->orderBy('id')->get()->toArray();
foreach ($survey->courseContentEvaluationForms()->with('user')->lazyById(100) as $reply) {
$name = $reply->user->name ?? '未填写姓名';
self::row($summary, $sr++, [$reply->id, $reply->snapshot['title'] ?? $survey->title, $name, $reply->user->no ?? '', $reply->user->company_name ?? '', $reply->user->company_position ?? '', $reply->created_at, $reply->updated_at]);
$replyStart = $sectionStart = $dr;
$section = '';
$number = 0;
foreach (SurveySchema::replyQuestions($current, $reply->data, $reply->snapshot) as $q) {
if ($q['edit_input'] === 'section') {
if ($section !== '' && $dr > $sectionStart) $sectionRanges[] = [$sectionStart, $dr - 1];
$sectionStart = $dr;
$section = $q['name'];
$hasSections = true;
continue;
}
$questionStart = $dr;
$hidden = !empty($q['hidden']);
$position = $hidden ? '' : ++$number;
$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;
}
$fields = $q['edit_input'] === 'multi_dimension' ? ($q['dimensions'] ?? []) : [$q];
foreach ($fields as $field) {
$options = array_map(fn ($o) => $o['value'].(isset($o['score']) ? '('.$o['score'].'分)' : ''), $field['select_item'] ?? []);
self::row($detail, $dr++, [$reply->id, $name, $section, $position, $q['name'], $q['edit_input'] === 'multi_dimension' ? $field['name'] : '', $hidden ? '' : ($field['display_value'] ?? ''), $extra, self::TYPES[$field['edit_input']] ?? $field['edit_input'], $field['help'] ?? $q['help'] ?? '', implode("\n", $options)]);
}
if ($q['edit_input'] === 'multi_dimension' && $dr > $questionStart) {
$hasDimensions = true;
$questionRanges[] = [$questionStart, $dr - 1];
} elseif ($dr > $questionStart) $plainRows[] = $questionStart;
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 ? '已打包' : '文件缺失,未打包']);
}
}
if ($dr > $replyStart) $replyRanges[] = [$replyStart, $dr - 1];
if ($section !== '' && $dr > $sectionStart) $sectionRanges[] = [$sectionStart, $dr - 1];
}
if (!$hasDimensions) $detail->removeColumn('F');
if (!$hasSections) $detail->removeColumn('C');
$titleColumn = $hasSections ? 'E' : 'D';
$numberColumn = $hasSections ? 'D' : 'C';
$dimensionColumn = $hasSections ? 'F' : 'E';
foreach ($replyRanges as [$start, $end]) {
self::merge($detail, 'A', $start, $end);
self::merge($detail, 'B', $start, $end);
}
if ($hasSections) foreach ($sectionRanges as [$start, $end]) self::merge($detail, 'C', $start, $end);
foreach ($questionRanges as [$start, $end]) {
self::merge($detail, $titleColumn, $start, $end);
self::merge($detail, $numberColumn, $start, $end);
}
// In mixed questionnaires a plain question spans the unused dimension cell.
if ($hasDimensions) foreach ($plainRows as $row) $detail->mergeCells($titleColumn.$row.':'.$dimensionColumn.$row);
foreach ($book->getAllSheets() as $sheet) {
$sheet->freezePane('C2');
$sheet->setAutoFilter($sheet->calculateWorksheetDimension());
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->getStyle($sheet->calculateWorksheetDimension())->getAlignment()->setWrapText(true)->setVertical('top');
foreach (range(1, Coordinate::columnIndexFromString($sheet->getHighestColumn())) as $col) $sheet->getColumnDimensionByColumn($col)->setWidth($col < 3 ? 18 : 32);
}
foreach ($detail->getMergeCells() as $range) $detail->getStyle($range)->getAlignment()->setVertical('center');
(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 merge($sheet, string $column, int $start, int $end): void
{
if ($end > $start) $sheet->mergeCells($column.$start.':'.$column.$end);
}
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);
}
}
}