:"|?*\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); } } }