|
|
@@ -2,8 +2,6 @@
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
-use App\DTO\ExamAnalysisDataDto;
|
|
|
-use App\DTO\ReportPayloadDto;
|
|
|
use App\Models\Paper;
|
|
|
use App\Models\Student;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
@@ -71,12 +69,44 @@ class ExamPdfExportService
|
|
|
/**
|
|
|
* 【优化方案】生成统一PDF(卷子 + 判卷一页完成)
|
|
|
* 效率提升40-50%,只需生成一次PDF
|
|
|
+ *
|
|
|
+ * @param string $paperId 试卷ID
|
|
|
+ * @param bool|null $includeKpExplain 是否包含知识点讲解,null则使用配置文件默认值
|
|
|
+ * @return string|null PDF URL
|
|
|
*/
|
|
|
- public function generateUnifiedPdf(string $paperId): ?string
|
|
|
+ public function generateUnifiedPdf(string $paperId, ?bool $includeKpExplain = null): ?string
|
|
|
{
|
|
|
- Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', ['paper_id' => $paperId]);
|
|
|
+ // 【临时禁用】强制不包含知识点讲解,等待后续修复
|
|
|
+ $includeKpExplain = false;
|
|
|
+
|
|
|
+ // 决定是否包含知识点讲解
|
|
|
+ // if ($includeKpExplain === null) {
|
|
|
+ // $includeKpExplain = config('pdf.include_kp_explain_default', false);
|
|
|
+ // }
|
|
|
+
|
|
|
+ Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'include_kp_explain' => $includeKpExplain,
|
|
|
+ ]);
|
|
|
|
|
|
try {
|
|
|
+ // 步骤0:获取知识点讲解HTML(如需要)
|
|
|
+ $kpExplainHtml = null;
|
|
|
+ if ($includeKpExplain) {
|
|
|
+ Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
|
|
|
+ $kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
|
|
|
+ if ($kpExplainHtml) {
|
|
|
+ // 对知识点讲解HTML进行内联资源处理(与服务端公式渲染)
|
|
|
+ $kpExplainHtml = $this->inlineExternalResources($kpExplainHtml);
|
|
|
+ Log::info('generateUnifiedPdf: 知识点讲解HTML获取并处理成功', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'length' => strlen($kpExplainHtml),
|
|
|
+ ]);
|
|
|
+ } else {
|
|
|
+ Log::warning('generateUnifiedPdf: 知识点讲解HTML获取失败,将跳过', ['paper_id' => $paperId]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// 步骤1:同时渲染两个页面的HTML
|
|
|
Log::info('generateUnifiedPdf: 开始渲染试卷HTML', ['paper_id' => $paperId]);
|
|
|
$examHtml = $this->renderExamHtml($paperId, includeAnswer: false, useGradingView: false);
|
|
|
@@ -98,13 +128,17 @@ class ExamPdfExportService
|
|
|
|
|
|
// 步骤2:插入分页符,合并HTML
|
|
|
Log::info('generateUnifiedPdf: 开始合并HTML(保留原始样式)', ['paper_id' => $paperId]);
|
|
|
- $unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml);
|
|
|
+ $unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, $kpExplainHtml);
|
|
|
if (! $unifiedHtml) {
|
|
|
Log::error('ExamPdfExportService: HTML合并失败', ['paper_id' => $paperId]);
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
- Log::info('generateUnifiedPdf: HTML合并完成(将直接生成PDF,不使用pdfunite)', ['paper_id' => $paperId, 'length' => strlen($unifiedHtml)]);
|
|
|
+ Log::info('generateUnifiedPdf: HTML合并完成(将直接生成PDF,不使用pdfunite)', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'length' => strlen($unifiedHtml),
|
|
|
+ 'has_kp_explain' => ! empty($kpExplainHtml),
|
|
|
+ ]);
|
|
|
|
|
|
// 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
|
|
|
Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
|
|
|
@@ -298,174 +332,37 @@ class ExamPdfExportService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 保存合并PDF URL到数据库
|
|
|
- */
|
|
|
- private function saveAllPdfUrlToDatabase(string $paperId, string $url): void
|
|
|
- {
|
|
|
- try {
|
|
|
- \App\Models\Paper::where('paper_id', $paperId)->update([
|
|
|
- 'all_pdf_url' => $url,
|
|
|
- ]);
|
|
|
- Log::debug('保存all_pdf_url成功', ['paper_id' => $paperId, 'url' => $url]);
|
|
|
- } catch (\Exception $e) {
|
|
|
- Log::error('保存all_pdf_url失败', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'url' => $url,
|
|
|
- 'error' => $e->getMessage(),
|
|
|
- ]);
|
|
|
- throw $e;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 生成学情分析 PDF
|
|
|
+ * 【新增】获取知识点讲解HTML
|
|
|
*/
|
|
|
- public function generateAnalysisReportPdf(string $paperId, string $studentId, ?string $recordId = null): ?string
|
|
|
+ private function fetchKnowledgeExplanationHtml(string $paperId): ?string
|
|
|
{
|
|
|
- if (function_exists('set_time_limit')) {
|
|
|
- @set_time_limit(240);
|
|
|
- }
|
|
|
-
|
|
|
try {
|
|
|
- // 【调试】打印输入参数
|
|
|
- Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'student_id' => $studentId,
|
|
|
- 'record_id' => $recordId,
|
|
|
- ]);
|
|
|
-
|
|
|
- // 构建分析数据
|
|
|
- $analysisData = $this->buildAnalysisData($paperId, $studentId);
|
|
|
- if (! $analysisData) {
|
|
|
- Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'student_id' => $studentId,
|
|
|
- ]);
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- Log::info('ExamPdfExportService: buildAnalysisData返回数据', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'student_id' => $studentId,
|
|
|
- 'analysisData_keys' => array_keys($analysisData),
|
|
|
- 'mastery_count' => count($analysisData['mastery']['items'] ?? []),
|
|
|
- 'questions_count' => count($analysisData['questions'] ?? []),
|
|
|
- ]);
|
|
|
-
|
|
|
- // 创建DTO
|
|
|
- $dto = ExamAnalysisDataDto::fromArray($analysisData);
|
|
|
- $payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
|
|
|
-
|
|
|
- // 【调试】打印传给模板的数据
|
|
|
- $templateData = $payloadDto->toArray();
|
|
|
- Log::info('ExamPdfExportService: 传给模板的数据', [
|
|
|
- 'paper' => $templateData['paper'] ?? null,
|
|
|
- 'student' => $templateData['student'] ?? null,
|
|
|
- 'mastery' => $templateData['mastery'] ?? null,
|
|
|
- 'parent_mastery_levels' => $templateData['parent_mastery_levels'] ?? null, // 新增:检查父节点掌握度
|
|
|
- 'questions_count' => count($templateData['questions'] ?? []),
|
|
|
- 'insights_count' => count($templateData['question_insights'] ?? []),
|
|
|
- 'recommendations_count' => count($templateData['recommendations'] ?? []),
|
|
|
- ]);
|
|
|
-
|
|
|
- // 渲染HTML
|
|
|
- $html = view('exam-analysis.pdf-report', $templateData)->render();
|
|
|
- if (! $html) {
|
|
|
- Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
+ $url = route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]);
|
|
|
|
|
|
- // 生成PDF
|
|
|
- $pdfBinary = $this->buildPdf($html);
|
|
|
- if (! $pdfBinary) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- // 保存PDF
|
|
|
- $version = time();
|
|
|
- $path = "analysis_reports/{$paperId}_{$studentId}_{$version}.pdf";
|
|
|
- $url = $this->pdfStorageService->put($path, $pdfBinary);
|
|
|
- if (! $url) {
|
|
|
- Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
|
|
|
+ $response = Http::get($url);
|
|
|
+ if ($response->successful()) {
|
|
|
+ $html = $response->body();
|
|
|
+ if (! empty(trim($html))) {
|
|
|
+ Log::info('ExamPdfExportService: 成功获取知识点讲解HTML', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'length' => strlen($html),
|
|
|
+ ]);
|
|
|
|
|
|
- return null;
|
|
|
+ return $this->ensureUtf8Html($html);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // 保存URL到数据库
|
|
|
- $this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url);
|
|
|
-
|
|
|
- return $url;
|
|
|
-
|
|
|
- } catch (\Throwable $e) {
|
|
|
- Log::error('ExamPdfExportService: 生成学情分析PDF失败', [
|
|
|
+ Log::warning('ExamPdfExportService: 获取知识点讲解HTML失败', [
|
|
|
'paper_id' => $paperId,
|
|
|
- 'student_id' => $studentId,
|
|
|
- 'record_id' => $recordId,
|
|
|
- 'error' => $e->getMessage(),
|
|
|
- 'exception' => get_class($e),
|
|
|
- 'trace' => $e->getTraceAsString(),
|
|
|
+ 'url' => $url,
|
|
|
]);
|
|
|
|
|
|
return null;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 渲染并存储试卷PDF
|
|
|
- */
|
|
|
- private function renderAndStoreExamPdf(
|
|
|
- string $paperId,
|
|
|
- bool $includeAnswer,
|
|
|
- string $suffix,
|
|
|
- bool $useGradingView = false
|
|
|
- ): ?string {
|
|
|
- // 放宽脚本执行时间
|
|
|
- if (function_exists('set_time_limit')) {
|
|
|
- @set_time_limit(240);
|
|
|
- }
|
|
|
|
|
|
- try {
|
|
|
- $html = $this->renderExamHtml($paperId, $includeAnswer, $useGradingView);
|
|
|
- if (! $html) {
|
|
|
- Log::error('ExamPdfExportService: 渲染HTML为空', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'include_answer' => $includeAnswer,
|
|
|
- 'use_grading_view' => $useGradingView,
|
|
|
- ]);
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- $pdfBinary = $this->buildPdf($html);
|
|
|
- if (! $pdfBinary) {
|
|
|
- Log::error('ExamPdfExportService: buildPdf为空', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'include_answer' => $includeAnswer,
|
|
|
- 'use_grading_view' => $useGradingView,
|
|
|
- ]);
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- $path = "exams/{$paperId}_{$suffix}.pdf";
|
|
|
- $url = $this->pdfStorageService->put($path, $pdfBinary);
|
|
|
- if (! $url) {
|
|
|
- Log::error('ExamPdfExportService: 保存PDF失败', ['path' => $path]);
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- return $url;
|
|
|
-
|
|
|
- } catch (\Throwable $e) {
|
|
|
- Log::error('ExamPdfExportService: 生成PDF失败', [
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ Log::warning('ExamPdfExportService: 获取知识点讲解HTML异常', [
|
|
|
'paper_id' => $paperId,
|
|
|
- 'suffix' => $suffix,
|
|
|
'error' => $e->getMessage(),
|
|
|
- 'exception' => get_class($e),
|
|
|
- 'trace' => $e->getTraceAsString(),
|
|
|
]);
|
|
|
|
|
|
return null;
|
|
|
@@ -473,52 +370,55 @@ class ExamPdfExportService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 渲染试卷HTML(重构版)
|
|
|
+ * 【新增】渲染试卷HTML(通过HTTP调用路由)
|
|
|
*/
|
|
|
private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
|
|
|
{
|
|
|
- // 直接构造请求URL,使用路由生成HTML
|
|
|
- $routeName = $useGradingView
|
|
|
- ? 'filament.admin.auth.intelligent-exam.grading'
|
|
|
- : 'filament.admin.auth.intelligent-exam.pdf';
|
|
|
+ try {
|
|
|
+ // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
|
|
|
+ $routeName = $useGradingView
|
|
|
+ ? 'filament.admin.auth.intelligent-exam.grading'
|
|
|
+ : 'filament.admin.auth.intelligent-exam.pdf';
|
|
|
|
|
|
- $url = route($routeName, ['paper_id' => $paperId, 'answer' => $includeAnswer ? 'true' : 'false']);
|
|
|
+ $url = route($routeName, ['paper_id' => $paperId, 'answer' => $includeAnswer ? 'true' : 'false']);
|
|
|
|
|
|
- // 使用HTTP客户端获取渲染后的HTML
|
|
|
- try {
|
|
|
$response = Http::get($url);
|
|
|
if ($response->successful()) {
|
|
|
$html = $response->body();
|
|
|
if (! empty(trim($html))) {
|
|
|
return $this->ensureUtf8Html($html);
|
|
|
- } else {
|
|
|
- Log::warning('ExamPdfExportService: HTTP返回的HTML为空,使用备用方案', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'url' => $url,
|
|
|
- ]);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'url' => $url,
|
|
|
+ ]);
|
|
|
+
|
|
|
} catch (\Exception $e) {
|
|
|
- Log::warning('ExamPdfExportService: 通过HTTP获取HTML失败,使用备用方案', [
|
|
|
+ Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML异常', [
|
|
|
'paper_id' => $paperId,
|
|
|
'error' => $e->getMessage(),
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
- // 备用方案:直接渲染视图(如果路由不可用)
|
|
|
+ // 备用方案:直接渲染视图
|
|
|
+ return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 备用方案:直接渲染视图生成试卷HTML
|
|
|
+ */
|
|
|
+ private function renderExamHtmlFromView(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
|
|
|
+ {
|
|
|
try {
|
|
|
$paper = Paper::with('questions')->find($paperId);
|
|
|
if (! $paper) {
|
|
|
- Log::error('ExamPdfExportService: 试卷不存在,备用方案无法渲染', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'include_answer' => $includeAnswer,
|
|
|
- 'use_grading_view' => $useGradingView,
|
|
|
- ]);
|
|
|
+ Log::error('ExamPdfExportService: 试卷不存在', ['paper_id' => $paperId]);
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- // 检查试卷是否有题目
|
|
|
if ($paper->questions->isEmpty()) {
|
|
|
Log::error('ExamPdfExportService: 试卷没有题目数据', [
|
|
|
'paper_id' => $paperId,
|
|
|
@@ -530,38 +430,25 @@ class ExamPdfExportService
|
|
|
|
|
|
$viewName = $useGradingView ? 'pdf.exam-grading' : 'pdf.exam-paper';
|
|
|
|
|
|
- // 【修复】构造视图需要的 $questions、$student、$teacher 变量
|
|
|
+ // 构造视图需要的变量
|
|
|
$questions = ['choice' => [], 'fill' => [], 'answer' => []];
|
|
|
foreach ($paper->questions as $pq) {
|
|
|
- $type = strtolower($pq->question_type ?? 'answer');
|
|
|
- if (! isset($questions[$type])) {
|
|
|
- $type = 'answer';
|
|
|
- }
|
|
|
- $questions[$type][] = (object) [
|
|
|
- 'id' => $pq->question_bank_id,
|
|
|
- 'question_number' => $pq->question_number,
|
|
|
- 'content' => $pq->question_text ?? '',
|
|
|
- 'stem' => $pq->question_text ?? '',
|
|
|
- 'answer' => $pq->correct_answer ?? '',
|
|
|
- 'solution' => $pq->solution ?? '',
|
|
|
- 'difficulty' => $pq->difficulty ?? 0.5,
|
|
|
- 'score' => $pq->score ?? 5,
|
|
|
- 'question_type' => $type,
|
|
|
- 'math_processed' => true,
|
|
|
- ];
|
|
|
- }
|
|
|
- foreach (['choice', 'fill', 'answer'] as $t) {
|
|
|
- if (! empty($questions[$t])) {
|
|
|
- usort($questions[$t], fn ($a, $b) => ($a->question_number ?? 0) <=> ($b->question_number ?? 0));
|
|
|
- }
|
|
|
+ $qType = $this->normalizeQuestionType($pq->question_type ?? 'answer');
|
|
|
+ $questions[$qType][] = $pq;
|
|
|
}
|
|
|
|
|
|
- $studentModel = $paper->student_id ? \App\Models\Student::where('student_id', $paper->student_id)->first() : null;
|
|
|
- $teacherModel = $paper->teacher_id ? \App\Models\Teacher::where('teacher_id', $paper->teacher_id)->first() : null;
|
|
|
+ $studentModel = \App\Models\Student::find($paper->student_id);
|
|
|
+ $teacherModel = \App\Models\Teacher::find($paper->teacher_id);
|
|
|
$student = ['name' => $studentModel->name ?? ($paper->student_id ?? '________'), 'grade' => $studentModel->grade ?? '________'];
|
|
|
$teacher = ['name' => $teacherModel->name ?? ($paper->teacher_id ?? '________')];
|
|
|
|
|
|
- $html = view($viewName, ['paper' => $paper, 'questions' => $questions, 'includeAnswer' => $includeAnswer, 'student' => $student, 'teacher' => $teacher])->render();
|
|
|
+ $html = view($viewName, [
|
|
|
+ 'paper' => $paper,
|
|
|
+ 'questions' => $questions,
|
|
|
+ 'includeAnswer' => $includeAnswer,
|
|
|
+ 'student' => $student,
|
|
|
+ 'teacher' => $teacher,
|
|
|
+ ])->render();
|
|
|
|
|
|
if (empty(trim($html))) {
|
|
|
Log::error('ExamPdfExportService: 视图渲染结果为空', [
|
|
|
@@ -1323,39 +1210,41 @@ class ExamPdfExportService
|
|
|
|
|
|
/**
|
|
|
* 将CDN资源替换为内联资源
|
|
|
- * 【关键修复】避免Chrome在容器中加载CDN资源超时
|
|
|
+ * 【关键修复】避免Chrome在容器中加载CDN资源超时,同时支持本地路径
|
|
|
*/
|
|
|
private function inlineExternalResources(string $html): string
|
|
|
{
|
|
|
+ // 检查是否包含 KaTeX 资源(CDN 或本地)
|
|
|
+ $hasKatexCdn = strpos($html, 'cdn.jsdelivr.net/npm/katex') !== false;
|
|
|
+ $hasKatexLocal = strpos($html, '/js/katex.min.js') !== false || strpos($html, '/css/katex/katex.min.css') !== false;
|
|
|
+
|
|
|
// 【调试】记录HTML内容信息
|
|
|
Log::warning('ExamPdfExportService: inlineExternalResources', [
|
|
|
'html_length' => strlen($html),
|
|
|
- 'has_katex_cdn' => strpos($html, 'cdn.jsdelivr.net/npm/katex') !== false,
|
|
|
- 'html_head_preview' => substr($html, 0, 1000),
|
|
|
+ 'has_katex_cdn' => $hasKatexCdn,
|
|
|
+ 'has_katex_local' => $hasKatexLocal,
|
|
|
]);
|
|
|
|
|
|
- // 检查是否包含KaTeX CDN链接
|
|
|
- if (strpos($html, 'cdn.jsdelivr.net/npm/katex') === false) {
|
|
|
- Log::warning('ExamPdfExportService: HTML中没有KaTeX CDN链接,跳过内联');
|
|
|
+ // 如果既没有 CDN 也没有本地链接,跳过
|
|
|
+ if (! $hasKatexCdn && ! $hasKatexLocal) {
|
|
|
+ Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
|
|
|
|
|
|
return $html;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
- // 读取本地KaTeX CSS文件并内联
|
|
|
+ // 读取并内联 KaTeX CSS(无论 CDN 还是本地)
|
|
|
$katexCssPath = public_path('css/katex/katex.min.css');
|
|
|
if (file_exists($katexCssPath)) {
|
|
|
$katexCss = file_get_contents($katexCssPath);
|
|
|
|
|
|
- // 修复字体路径:将相对路径改为绝对路径(使用data URI或绝对路径)
|
|
|
- // KaTeX CSS中的字体引用格式: url(fonts/KaTeX_xxx.woff2)
|
|
|
+ // 修复字体路径:将相对路径改为 data URI
|
|
|
$fontsDir = public_path('css/katex/fonts');
|
|
|
$katexCss = preg_replace_callback(
|
|
|
'/url\(["\']?fonts\/([^"\')\s]+)["\']?\)/i',
|
|
|
function ($matches) use ($fontsDir) {
|
|
|
$fontFile = $fontsDir.'/'.$matches[1];
|
|
|
if (file_exists($fontFile)) {
|
|
|
- // 将字体转换为data URI(适用于PDF生成)
|
|
|
$fontData = base64_encode(file_get_contents($fontFile));
|
|
|
$mimeType = str_ends_with($matches[1], '.woff2') ? 'font/woff2' : 'font/woff';
|
|
|
|
|
|
@@ -1367,17 +1256,28 @@ class ExamPdfExportService
|
|
|
$katexCss
|
|
|
);
|
|
|
|
|
|
- // 替换CDN CSS链接为内联样式
|
|
|
- $html = preg_replace(
|
|
|
- '/<link[^>]*href=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*katex\.min\.css["\'][^>]*>/i',
|
|
|
- '<style type="text/css">'.$katexCss.'</style>',
|
|
|
- $html
|
|
|
- );
|
|
|
+ // 替换 CDN CSS 链接
|
|
|
+ if ($hasKatexCdn) {
|
|
|
+ $html = preg_replace(
|
|
|
+ '/<link[^>]*href=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*katex\.min\.css["\'][^>]*>/i',
|
|
|
+ '<style type="text/css">'.$katexCss.'</style>',
|
|
|
+ $html
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 替换本地 CSS 链接
|
|
|
+ if ($hasKatexLocal) {
|
|
|
+ $html = preg_replace(
|
|
|
+ '/<link[^>]*href=["\']\/css\/katex\/katex\.min\.css["\'][^>]*>/i',
|
|
|
+ '<style type="text/css">'.$katexCss.'</style>',
|
|
|
+ $html
|
|
|
+ );
|
|
|
+ }
|
|
|
|
|
|
- Log::info('ExamPdfExportService: KaTeX CSS已内联(含字体data URI)');
|
|
|
+ Log::info('ExamPdfExportService: KaTeX CSS 已内联(含字体 data URI)');
|
|
|
}
|
|
|
|
|
|
- // 读取本地KaTeX JS并内联
|
|
|
+ // 读取本地 KaTeX JS(用于移除)
|
|
|
$katexJsPath = public_path('js/katex.min.js');
|
|
|
$autoRenderJsPath = public_path('js/auto-render.min.js');
|
|
|
|
|
|
@@ -1451,16 +1351,21 @@ class ExamPdfExportService
|
|
|
* 【新增】合并两个HTML页面,插入分页符
|
|
|
* 保留原始页面样式和结构,只在中间插入分页符
|
|
|
*/
|
|
|
- private function mergeHtmlWithPageBreak(string $examHtml, string $gradingHtml): ?string
|
|
|
+ private function mergeHtmlWithPageBreak(string $examHtml, string $gradingHtml, ?string $kpExplainHtml = null): ?string
|
|
|
{
|
|
|
try {
|
|
|
// 确保HTML编码正确
|
|
|
$examHtml = $this->ensureUtf8Html($examHtml);
|
|
|
$gradingHtml = $this->ensureUtf8Html($gradingHtml);
|
|
|
+ if ($kpExplainHtml) {
|
|
|
+ $kpExplainHtml = $this->ensureUtf8Html($kpExplainHtml);
|
|
|
+ }
|
|
|
|
|
|
// 提取body内容
|
|
|
$examBody = $this->extractBodyContent($examHtml);
|
|
|
$gradingBody = $this->extractBodyContent($gradingHtml);
|
|
|
+ // 知识点讲解使用专门的提取方法,避免嵌套完整HTML结构
|
|
|
+ $kpExplainBody = $kpExplainHtml ? $this->extractKpExplainContent($kpExplainHtml) : null;
|
|
|
|
|
|
if (empty($examBody) || empty($gradingBody)) {
|
|
|
Log::error('ExamPdfExportService: HTML内容提取失败', [
|
|
|
@@ -1473,15 +1378,18 @@ class ExamPdfExportService
|
|
|
|
|
|
// 提取head内容(保留原始样式和meta信息)
|
|
|
$examHead = $this->extractHeadContent($examHtml);
|
|
|
+ $kpExplainHead = $kpExplainHtml ? $this->extractHeadContent($kpExplainHtml) : null;
|
|
|
|
|
|
// 构建统一HTML文档(保留原始结构)
|
|
|
- $unifiedHtml = $this->buildUnifiedHtmlWithOriginalStructure($examHead, $examBody, $gradingBody);
|
|
|
+ $unifiedHtml = $this->buildUnifiedHtmlWithOriginalStructure($examHead, $examBody, $gradingBody, $kpExplainBody, $kpExplainHead);
|
|
|
|
|
|
Log::info('HTML合并成功(保留原始样式)', [
|
|
|
'exam_length' => strlen($examBody),
|
|
|
'grading_length' => strlen($gradingBody),
|
|
|
+ 'kp_explain_length' => $kpExplainBody ? strlen($kpExplainBody) : 0,
|
|
|
'unified_length' => strlen($unifiedHtml),
|
|
|
'head_length' => strlen($examHead),
|
|
|
+ 'has_kp_explain' => ! empty($kpExplainBody),
|
|
|
]);
|
|
|
|
|
|
return $unifiedHtml;
|
|
|
@@ -1525,27 +1433,89 @@ class ExamPdfExportService
|
|
|
return '<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 【新增】提取知识点讲解的核心内容
|
|
|
+ * 只提取 kp-explain 容器内部的内容,避免嵌套完整的HTML结构
|
|
|
+ */
|
|
|
+ private function extractKpExplainContent(string $html): string
|
|
|
+ {
|
|
|
+ // 如果 HTML 中包含嵌套的 <html> 标签,提取嵌套内容
|
|
|
+ if (preg_match('/<html[^>]*>(.*)<\/html>/is', $html, $htmlMatches)) {
|
|
|
+ $html = $htmlMatches[1];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果 HTML 中包含 <head> 标签,跳过 head
|
|
|
+ if (preg_match('/<head[^>]*>.*?<\/head>/is', $html, $headMatch)) {
|
|
|
+ $html = substr($html, strpos($html, '</head>') + 7);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果 HTML 中包含 <body> 标签,提取 body 内容
|
|
|
+ if (preg_match('/<body[^>]*>(.*)<\/body>/is', $html, $bodyMatches)) {
|
|
|
+ $html = $bodyMatches[1];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除可能存在的嵌套 <html>, <head>, <body> 标签
|
|
|
+ $html = preg_replace('/<\/?(html|head|body)[^>]*>/is', '', $html);
|
|
|
+
|
|
|
+ // 移除 script 标签和注释
|
|
|
+ $html = preg_replace('/<script[^>]*>.*?<\/script>/is', '', $html);
|
|
|
+ $html = preg_replace('/<!--[^>]*-->/is', '', $html);
|
|
|
+
|
|
|
+ return trim($html);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 【优化重构】构建统一的HTML文档(容器结构 + 消除空白页)
|
|
|
* 使用容器结构替代空的 page-break div,避免中间出现空白页
|
|
|
*/
|
|
|
- private function buildUnifiedHtmlWithOriginalStructure(string $examHead, string $examBody, string $gradingBody): string
|
|
|
+ private function buildUnifiedHtmlWithOriginalStructure(string $examHead, string $examBody, string $gradingBody, ?string $kpExplainBody = null, ?string $kpExplainHead = null): string
|
|
|
{
|
|
|
// 清洗内容:移除可能存在的分页符,避免双重分页
|
|
|
$examBody = $this->stripPageBreakElements($examBody);
|
|
|
$gradingBody = $this->stripPageBreakElements($gradingBody);
|
|
|
+ if ($kpExplainBody) {
|
|
|
+ $kpExplainBody = $this->stripPageBreakElements($kpExplainBody);
|
|
|
+ }
|
|
|
|
|
|
- // 构建优化的HTML结构,使用容器控制分页
|
|
|
- $headContent = $examHead.'
|
|
|
+ // 合并 head 内容:试卷 head + 知识点讲解 head(去重)+ 分页控制样式
|
|
|
+ $mergedHead = $examHead;
|
|
|
+
|
|
|
+ // 如果有知识点讲解 head,合并样式(避免重复)
|
|
|
+ if ($kpExplainHead) {
|
|
|
+ // 提取知识点讲解中的 <style> 内容并追加
|
|
|
+ if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $kpExplainHead, $styleMatches)) {
|
|
|
+ foreach ($styleMatches[0] as $idx => $styleTag) {
|
|
|
+ // 避免重复添加相同的样式
|
|
|
+ if (! str_contains($mergedHead, $styleMatches[1][$idx])) {
|
|
|
+ $mergedHead .= "\n ".$styleTag;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加分页控制样式
|
|
|
+ $headContent = $mergedHead.'
|
|
|
<style>
|
|
|
/* 容器基础样式 - 保持现有页面边距 */
|
|
|
.exam-part,
|
|
|
- .grading-part {
|
|
|
+ .grading-part,
|
|
|
+ .kp-explain-part {
|
|
|
width: 100%;
|
|
|
margin: 0;
|
|
|
padding: 0;
|
|
|
}
|
|
|
|
|
|
+ /* 试卷部分 - 只有在知识点讲解后才需要分页 */
|
|
|
+ .exam-part {
|
|
|
+ /* 如果有知识点讲解,需要在新页面开始(通过 kp-explain-part 的 break-after 控制) */
|
|
|
+ /* 如果没有知识点讲解,试卷从第一页开始,不需要分页 */
|
|
|
+ break-after: auto;
|
|
|
+ page-break-after: auto;
|
|
|
+ /* 确保顶部没有额外边距 */
|
|
|
+ margin-top: 0;
|
|
|
+ padding-top: 0;
|
|
|
+ }
|
|
|
+
|
|
|
/* 核心分页控制:只在 grading-part 上设置 */
|
|
|
.grading-part {
|
|
|
/* CSS3 新标准 */
|
|
|
@@ -1557,6 +1527,12 @@ class ExamPdfExportService
|
|
|
padding-top: 0;
|
|
|
}
|
|
|
|
|
|
+ /* 知识点讲解部分末尾强制分页(确保试卷从新页面开始) */
|
|
|
+ .kp-explain-part {
|
|
|
+ break-after: page;
|
|
|
+ page-break-after: always;
|
|
|
+ }
|
|
|
+
|
|
|
/* 防止试卷部分末尾分页 */
|
|
|
.exam-part {
|
|
|
/* 确保试卷内容连续 */
|
|
|
@@ -1591,6 +1567,35 @@ class ExamPdfExportService
|
|
|
}
|
|
|
</style>';
|
|
|
|
|
|
+ // 构建HTML内容
|
|
|
+ $bodyContent = '';
|
|
|
+
|
|
|
+ // 如果有知识点讲解,添加到最前面
|
|
|
+ if ($kpExplainBody) {
|
|
|
+ $bodyContent .= '
|
|
|
+ <!-- 知识点讲解部分 -->
|
|
|
+ <div class="kp-explain-part">
|
|
|
+'.$kpExplainBody.'
|
|
|
+ </div>
|
|
|
+';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加试卷部分
|
|
|
+ $bodyContent .= '
|
|
|
+ <!-- 试卷部分 - 连续显示 -->
|
|
|
+ <div class="exam-part">
|
|
|
+'.$examBody.'
|
|
|
+ </div>
|
|
|
+';
|
|
|
+
|
|
|
+ // 添加判卷部分
|
|
|
+ $bodyContent .= '
|
|
|
+ <!-- 判卷部分 - 强制新页面开始 -->
|
|
|
+ <div class="grading-part">
|
|
|
+'.$gradingBody.'
|
|
|
+ </div>
|
|
|
+';
|
|
|
+
|
|
|
return '<!DOCTYPE html>
|
|
|
<html lang="zh-CN">
|
|
|
<head>
|
|
|
@@ -1600,15 +1605,7 @@ class ExamPdfExportService
|
|
|
'.$headContent.'
|
|
|
</head>
|
|
|
<body>
|
|
|
- <!-- 试卷部分 - 连续显示 -->
|
|
|
- <div class="exam-part">
|
|
|
-'.$examBody.'
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 判卷部分 - 强制新页面开始(不使用空的 page-break div) -->
|
|
|
- <div class="grading-part">
|
|
|
-'.$gradingBody.'
|
|
|
- </div>
|
|
|
+'.$bodyContent.'
|
|
|
</body>
|
|
|
</html>';
|
|
|
}
|
|
|
@@ -2402,4 +2399,118 @@ class ExamPdfExportService
|
|
|
'questionType' => $questionType,
|
|
|
])->render();
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取知识点的讲解内容
|
|
|
+ *
|
|
|
+ * @param string $kpCode 知识点代码
|
|
|
+ * @param string $kpName 知识点名称
|
|
|
+ * @return string Markdown 格式的讲解内容
|
|
|
+ */
|
|
|
+ public function buildExplanation(string $kpCode, string $kpName): string
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ // 从数据库获取知识点讲解
|
|
|
+ $kp = \App\Models\KnowledgePoint::where('kp_code', $kpCode)->first();
|
|
|
+
|
|
|
+ if ($kp && ! empty($kp->explanation)) {
|
|
|
+ return $kp->explanation;
|
|
|
+ }
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ Log::warning('获取知识点讲解失败', [
|
|
|
+ 'kp_code' => $kpCode,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果数据库没有,返回默认讲解内容
|
|
|
+ return $this->getDefaultExplanation($kpCode, $kpName);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量获取知识点的讲解内容
|
|
|
+ *
|
|
|
+ * @param array $kpCodes 知识点代码数组
|
|
|
+ * @return array [kp_code => explanation]
|
|
|
+ */
|
|
|
+ public function buildExplanations(array $kpCodes): array
|
|
|
+ {
|
|
|
+ $result = [];
|
|
|
+
|
|
|
+ if (empty($kpCodes)) {
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 批量获取知识点讲解
|
|
|
+ $kps = \App\Models\KnowledgePoint::whereIn('kp_code', $kpCodes)->get()->keyBy('kp_code');
|
|
|
+
|
|
|
+ foreach ($kpCodes as $kpCode) {
|
|
|
+ $kp = $kps->get($kpCode);
|
|
|
+ if ($kp && ! empty($kp->explanation)) {
|
|
|
+ $result[$kpCode] = $kp->explanation;
|
|
|
+ } else {
|
|
|
+ $result[$kpCode] = $this->getDefaultExplanation($kpCode, $kpCode);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ Log::warning('批量获取知识点讲解失败', [
|
|
|
+ 'kp_codes' => $kpCodes,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // 失败时返回默认内容
|
|
|
+ foreach ($kpCodes as $kpCode) {
|
|
|
+ $result[$kpCode] = $this->getDefaultExplanation($kpCode, $kpCode);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取默认的知识点讲解内容
|
|
|
+ */
|
|
|
+ private function getDefaultExplanation(string $kpCode, string $kpName): string
|
|
|
+ {
|
|
|
+ // 默认讲解模板
|
|
|
+ return <<<MARKDOWN
|
|
|
+## 知识点
|
|
|
+(1) 核心定义:二元一次方程是含有两个未知数且每个未知数的次数都是 1 的方程,一般形式为 \$ax + by = c\$(其中 \$a,b,c\$ 为常数,且 \$a,b\$ 不同时为 0)。二元一次方程组是由两个(或两个以上)二元一次方程组成,常见为
|
|
|
+\$\$
|
|
|
+\begin{cases}
|
|
|
+a_1x + b_1y = c_1\\
|
|
|
+a_2x + b_2y = c_2
|
|
|
+\end{cases}
|
|
|
+\$\$
|
|
|
+方程组应用建模就是把题目中的数量关系翻译成方程组,通过求解得到实际问题的答案。
|
|
|
+
|
|
|
+(2) 性质定理:方程组的解必须同时满足方程组中的每一个方程,因此解是两个方程公共的 \$(x,y)\$。常用解法有代入消元法与加减消元法:代入消元法适合某个方程易表示成 \$x = \dots\$ 或 \$y = \dots\$;加减消元法适合两个方程中某个未知数的系数相同或相反,便于相加或相减消去一个未知数。建模时常用关系包括:总量关系(如"总数 = 部分之和")、单价数量总价关系(\$总价 = 单价 \times 数量\$)、路程速度时间关系(\$路程 = 速度 \times 时间\$)等。
|
|
|
+
|
|
|
+(3) 注意事项:设未知数要带单位与含义,例如"设甲买了 \$x\$ 支笔";列方程要对应同一类量,别把"支数"和"元数"混在一条等式里;检查条件是否满足题意(如数量应为整数且 \$> 0\$);消元后别忘了回代求另一个未知数;最后答案要写清对象与单位,并可把解代回原式检验。
|
|
|
+
|
|
|
+## 知识点应用
|
|
|
+- 典型例题:文具店买笔和本子。小明买了 2 支笔和 3 本本子共花 19 元;小红买了 3 支笔和 2 本本子共花 18 元。求每支笔和每本本子的单价。
|
|
|
+
|
|
|
+- 关键步骤:
|
|
|
+ 1. 设未知数并写出含义:设每支笔单价为 \$x\$ 元,每本本子单价为 \$y\$ 元。
|
|
|
+ 2. 根据题意列方程组:由"总价 = 单价 \times 数量"得
|
|
|
+ \$\$
|
|
|
+ \begin{cases}
|
|
|
+ 2x + 3y = 19\\
|
|
|
+ 3x + 2y = 18
|
|
|
+ \end{cases}
|
|
|
+ \$\$
|
|
|
+ 3. 选择消元并求解:用加减消元。将第一式乘 3、第二式乘 2:
|
|
|
+ \$\$
|
|
|
+ \begin{cases}
|
|
|
+ 6x + 9y = 57\\
|
|
|
+ 6x + 4y = 36
|
|
|
+ \end{cases}
|
|
|
+ \$\$
|
|
|
+ 相减得 \$5y = 21\$,所以 \$y = \dfrac{21}{5} = 4.2\$。代入 \$3x + 2y = 18\$ 得 \$3x + 8.4 = 18\$,所以 \$3x = 9.6\$,\$x = 3.2\$。
|
|
|
+
|
|
|
+- 结论:每支笔 3.2 元,每本本子 4.2 元(两者均 \$> 0\$,符合题意)。
|
|
|
+MARKDOWN;
|
|
|
+ }
|
|
|
}
|