Prechádzať zdrojové kódy

Merge branch 'fix/临考报告排版修正' of jyx/dcjxb.microservice into master

金逸霄 2 týždňov pred
rodič
commit
f900a2ba08

+ 77 - 15
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java

@@ -24,6 +24,10 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     private static final String TEMPLATE_RESOURCE = "templates/outlook-exam-sprint-report-template.html";
     private static final String DEFAULT_THEME_COLOR = "#448aff";
     private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
+    private static final int CHART_AXIS_LEFT = 34;
+    private static final int CHART_AXIS_TOP = 50;
+    private static final int CHART_AXIS_BOTTOM = 180;
+    private static final int CHART_AXIS_HEIGHT = CHART_AXIS_BOTTOM - CHART_AXIS_TOP;
 
     private final ObjectMapper objectMapper;
 
@@ -92,9 +96,9 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     }
 
     private String renderPastPaperVocabularyChart(OutlookExamSprintReportPayload.PastPaperVocabularyChart chart) {
-        int maxValue = Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint());
-        int totalHeight = barHeight(chart.totalWordCount(), maxValue);
-        int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), maxValue);
+        int axisMax = roundUpToStep(Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()), 250);
+        int totalHeight = barHeight(chart.totalWordCount(), axisMax);
+        int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), axisMax);
         double beforePercent = percentage(chart.unknownWordCountBeforeSprint(), chart.totalWordCount());
         double afterPercent = percentage(chart.unknownWordCountAfterSprint(), chart.totalWordCount());
 
@@ -104,9 +108,11 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("<div class='chart-box'>")
                 .append("<svg class='past-paper-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
                 .append(renderChartAxes(320))
-                .append("<rect class='chart-column total-column' x='84' y='").append(180 - totalHeight)
+                .append(renderYAxisTicks(axisMax, 250))
+                .append(renderXAxisTickMarks(112, 208))
+                .append("<rect class='chart-column total-column' x='84' y='").append(CHART_AXIS_BOTTOM - totalHeight)
                 .append("' width='56' height='").append(totalHeight).append("' rx='8' ry='8' fill='#448aff'/>")
-                .append("<rect class='chart-column unknown-column' x='180' y='").append(180 - unknownHeight)
+                .append("<rect class='chart-column unknown-column' x='180' y='").append(CHART_AXIS_BOTTOM - unknownHeight)
                 .append("' width='56' height='").append(unknownHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
                 .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>总词量</text>")
                 .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>生词量</text>")
@@ -126,8 +132,9 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     }
 
     private String renderHighFrequencyVocabularyChart(OutlookExamSprintReportPayload.HighFrequencyVocabularyChart chart) {
-        int basicHeight = barHeight(chart.basicCorePercent(), 100);
-        int highScoreHeight = barHeight(chart.highScorePercent(), 100);
+        int axisMax = 100;
+        int basicHeight = barHeight(chart.basicCorePercent(), axisMax);
+        int highScoreHeight = barHeight(chart.highScorePercent(), axisMax);
 
         return new StringBuilder()
                 .append("<div class='card'>")
@@ -135,9 +142,11 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("<div class='chart-box'>")
                 .append("<svg class='high-frequency-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='常考词汇掌握情况'>")
                 .append(renderChartAxes(320))
-                .append("<rect class='chart-column basic-core-column' x='84' y='").append(180 - basicHeight)
+                .append(renderYAxisTicks(axisMax, 20))
+                .append(renderXAxisTickMarks(112, 208))
+                .append("<rect class='chart-column basic-core-column' x='84' y='").append(CHART_AXIS_BOTTOM - basicHeight)
                 .append("' width='56' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
-                .append("<rect class='chart-column high-score-column' x='180' y='").append(180 - highScoreHeight)
+                .append("<rect class='chart-column high-score-column' x='180' y='").append(CHART_AXIS_BOTTOM - highScoreHeight)
                 .append("' width='56' height='").append(highScoreHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
                 .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>基础必会词</text>")
                 .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>高分拉分词</text>")
@@ -158,14 +167,17 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .mapToDouble(OutlookExamSprintReportPayload.VocabularyFrequencyBar::currentValue)
                 .max()
                 .orElse(1d);
-        int maxScaled = (int) Math.ceil(highest * 10d);
+        int axisMax = roundUpToStep((int) Math.ceil(highest), 50);
+        int maxScaled = axisMax * 10;
 
         StringBuilder builder = new StringBuilder();
         builder.append("<div class='card'>")
                 .append("<h3 class='card-title'>词频区间掌握度</h3>")
                 .append("<div class='chart-box'>")
                 .append("<svg class='frequency-band-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>")
-                .append(renderChartAxes(360));
+                .append(renderChartAxes(360))
+                .append(renderYAxisTicks(axisMax, 50))
+                .append(renderXAxisTickMarks(97, 187, 277));
 
         int[] xPositions = {70, 160, 250};
         String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
@@ -175,7 +187,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
             int x = xPositions[index];
 
             builder.append("<rect class='chart-column ").append(columnClasses[index]).append("' x='")
-                    .append(x).append("' y='").append(180 - height).append("' width='54' height='")
+                    .append(x).append("' y='").append(CHART_AXIS_BOTTOM - height).append("' width='54' height='")
                     .append(height).append("' rx='8' ry='8' fill='").append(safeColor(bar.themeColor())).append("'/>")
                     .append("<text class='donut-label-text' x='").append(x + 27).append("' y='198' text-anchor='middle'>")
                     .append(escape(bar.bandLabel())).append("</text>")
@@ -305,12 +317,52 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
 
     private String renderChartAxes(int width) {
         return new StringBuilder()
-                .append("<line class='chart-axis' x1='34' y1='18' x2='34' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
-                .append("<line class='chart-axis' x1='34' y1='180' x2='").append(width - 24)
-                .append("' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
+                .append("<line class='chart-axis' x1='").append(CHART_AXIS_LEFT).append("' y1='18' x2='")
+                .append(CHART_AXIS_LEFT).append("' y2='").append(CHART_AXIS_BOTTOM)
+                .append("' stroke='#9aa6b2' stroke-width='1'/>")
+                .append("<line class='chart-axis' x1='").append(CHART_AXIS_LEFT).append("' y1='")
+                .append(CHART_AXIS_BOTTOM).append("' x2='").append(width - 24)
+                .append("' y2='").append(CHART_AXIS_BOTTOM).append("' stroke='#9aa6b2' stroke-width='1'/>")
                 .toString();
     }
 
+    private String renderYAxisTicks(int axisMax, int tickStep) {
+        if (axisMax <= 0 || tickStep <= 0) {
+            return "";
+        }
+
+        StringBuilder builder = new StringBuilder();
+        for (int tickValue = 0; tickValue <= axisMax; tickValue += tickStep) {
+            int y = CHART_AXIS_BOTTOM - (int) Math.round((tickValue / (double) axisMax) * CHART_AXIS_HEIGHT);
+            builder.append("<line class='chart-axis-tick chart-axis-tick-y' x1='")
+                    .append(CHART_AXIS_LEFT - 4).append("' y1='").append(y)
+                    .append("' x2='").append(CHART_AXIS_LEFT).append("' y2='").append(y)
+                    .append("' stroke='#9aa6b2' stroke-width='1'/>")
+                    .append("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='")
+                    .append(CHART_AXIS_LEFT - 8).append("' y='").append(y + 4)
+                    .append("' text-anchor='end' fill='#7f8b97' font-size='11'>")
+                    .append(tickValue)
+                    .append("</text>");
+        }
+        return builder.toString();
+    }
+
+    private String renderXAxisTickMarks(int... xCenters) {
+        if (xCenters == null || xCenters.length == 0) {
+            return "";
+        }
+
+        StringBuilder builder = new StringBuilder();
+        for (int xCenter : xCenters) {
+            builder.append("<line class='chart-axis-tick chart-axis-tick-x' x1='")
+                    .append(xCenter).append("' y1='").append(CHART_AXIS_BOTTOM)
+                    .append("' x2='").append(xCenter).append("' y2='")
+                    .append(CHART_AXIS_BOTTOM + 5)
+                    .append("' stroke='#9aa6b2' stroke-width='1'/>");
+        }
+        return builder.toString();
+    }
+
     private int barHeight(int value, int maxValue) {
         if (maxValue <= 0) {
             return 0;
@@ -319,6 +371,16 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         return Math.max(16, (int) Math.round(ratio * 130));
     }
 
+    private int roundUpToStep(int value, int step) {
+        if (value <= 0) {
+            return step;
+        }
+        if (step <= 0) {
+            return value;
+        }
+        return ((value + step - 1) / step) * step;
+    }
+
     private double percentage(int numerator, int denominator) {
         if (denominator <= 0) {
             return 0d;

+ 76 - 30
abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html

@@ -17,6 +17,8 @@
             font-family: MiSans, ReportFont, sans-serif;
             font-size: 14px;
             line-height: 1.72;
+            -webkit-print-color-adjust: exact;
+            print-color-adjust: exact;
         }
 
         .report-container {
@@ -26,6 +28,7 @@
             padding: 32px;
             border-radius: 12px;
             border: 1px solid #e7edf5;
+            box-shadow: 0 2px 15px rgba(0, 0, 0, 0.06);
         }
 
         h1.report-title {
@@ -73,6 +76,7 @@
         .analysis-cell {
             width: 50%;
             vertical-align: top;
+            height: 100%;
         }
 
         .analysis-cell-left {
@@ -84,11 +88,14 @@
         }
 
         .card {
-            background: #fff;
+            background: #fafbfc;
             border: 1px solid #e7edf5;
-            border-radius: 12px;
-            padding: 16px;
+            border-radius: 10px;
+            padding: 20px;
             page-break-inside: avoid;
+            display: flex;
+            flex-direction: column;
+            min-height: 370px;
         }
 
         .card-title {
@@ -100,8 +107,8 @@
         }
 
         .chart-box {
-            height: 190px;
-            margin-bottom: 10px;
+            height: 220px;
+            margin: 10px 0;
             border-radius: 10px;
             background: #f8fbff;
             text-align: center;
@@ -156,16 +163,17 @@
         }
 
         .freq-card {
-            background: #f8f9fb;
-            border: 1px solid #e3e9f2;
-            border-radius: 10px;
-            padding: 14px 12px;
+            position: relative;
+            background: #f8f9fa;
+            border: 2px solid #e0e0e0;
+            border-radius: 8px;
+            padding: 15px;
             page-break-inside: avoid;
         }
 
         .freq-card.active {
-            background: #eaf4ff;
-            border-color: #6ba7ff;
+            background: #e3f2fd;
+            border-color: #2196f3;
         }
 
         .freq-header {
@@ -178,21 +186,28 @@
 
         .badge {
             display: inline-block;
-            margin-left: 6px;
-            padding: 2px 8px;
-            border-radius: 999px;
-            background: #fff1e7;
-            color: #ff7d00;
+            margin-left: 5px;
+            padding: 2px 6px;
+            border-radius: 10px;
+            background: #2196f3;
+            color: #fff;
             font-size: 12px;
             vertical-align: middle;
         }
 
+        .crown {
+            position: absolute;
+            top: -10px;
+            right: 10px;
+            font-size: 20px;
+        }
+
         .freq-progress {
             background: #dfe6ef;
             border-radius: 999px;
             height: 8px;
             overflow: hidden;
-            margin-bottom: 10px;
+            margin-bottom: 12px;
         }
 
         .freq-progress > span {
@@ -204,7 +219,7 @@
         .freq-data {
             font-family: MiSans, ReportFont, sans-serif;
             color: #4a5568;
-            font-size: 13px;
+            font-size: 14px;
             line-height: 1.72;
         }
 
@@ -217,34 +232,41 @@
         }
 
         .suggest-note {
-            background: #fff8e7;
-            border-left: 4px solid #ffb13d;
-            border-radius: 8px;
-            padding: 10px 12px;
-            color: #7b5a2f;
+            background: #fff8e1;
+            border-left: 4px solid #ffc107;
+            border-radius: 4px;
+            padding: 12px 20px;
+            color: #e65100;
             margin-bottom: 12px;
         }
 
         .suggest-box {
             background: #edf3fc;
             border-radius: 10px;
-            padding: 14px;
+            padding: 25px 30px;
         }
 
         .suggest-item + .suggest-item {
-            margin-top: 10px;
+            margin-top: 20px;
+        }
+
+        .suggest-item h4 {
+            font-size: 16px;
+            color: #2b4c8a;
+            margin: 0 0 8px;
         }
 
         .suggest-item p {
-            margin: 4px 0 0;
+            margin: 0;
             color: #4a5568;
+            line-height: 1.72;
         }
 
         .student-case {
             background: #fff7ed;
             border: 1px solid #f5dcc2;
-            border-radius: 12px;
-            padding: 16px;
+            border-radius: 10px;
+            padding: 30px;
             page-break-inside: avoid;
         }
 
@@ -259,9 +281,9 @@
         }
 
         .case-chart-cell {
-            width: 320px;
+            width: 260px;
             vertical-align: middle;
-            padding-right: 20px;
+            padding-right: 30px;
         }
 
         .case-info-cell {
@@ -280,6 +302,28 @@
 
         .case-info p {
             margin: 0 0 8px;
+            line-height: 2;
+        }
+
+        .case-info h3 {
+            color: #e65c00;
+            margin: 0 0 15px;
+        }
+
+        @media print {
+            .section:nth-of-type(2) {
+                margin-top: 0;
+            }
+
+            .page-break-before-module2 {
+                break-before: page;
+                page-break-before: always;
+                height: 0;
+                margin: 0;
+                padding: 0;
+                line-height: 0;
+                font-size: 0;
+            }
         }
     </style>
 </head>
@@ -302,6 +346,8 @@
         </table>
     </div>
 
+    <div class="page-break-before-module2" aria-hidden="true"></div>
+
     <div class="section">
         <h2 class="section-title">模块二:科学备考建议</h2>
         {{studySuggestionSection}}

+ 14 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java

@@ -73,6 +73,20 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("低频词:70.4(酌情学习)");
     }
 
+    @Test
+    void renderAddsAxisTicksToThreeBarCharts() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("class='chart-axis-tick chart-axis-tick-y'")
+                .contains("class='chart-axis-tick chart-axis-tick-x'")
+                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>1000</text>")
+                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>100</text>")
+                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>200</text>");
+    }
+
     @Test
     void supportsOnlyOutlookReportType() {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

+ 2 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java

@@ -45,6 +45,7 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .containsPattern("\\.analysis-table\\s*\\{[^}]*width\\s*:\\s*100%\\s*;[^}]*table-layout\\s*:\\s*fixed\\s*;[^}]*}")
                 .containsPattern("\\.analysis-row\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
                 .containsPattern("\\.card\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .containsPattern("\\.card\\s*\\{[^}]*display\\s*:\\s*flex\\s*;[^}]*flex-direction\\s*:\\s*column\\s*;[^}]*min-height\\s*:\\s*370px\\s*;[^}]*}")
                 .containsPattern("\\.card-title\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*font-size\\s*:\\s*16px\\s*;[^}]*font-weight\\s*:\\s*600\\s*;[^}]*}")
                 .contains(".frequency-table")
                 .contains(".frequency-row")
@@ -64,6 +65,7 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .contains(".case-chart-cell")
                 .contains(".case-info-cell")
                 .containsPattern("\\.student-case\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .containsPattern("@media print\\s*\\{[^}]*\\.section:nth-of-type\\(2\\)\\s*\\{[^}]*margin-top\\s*:\\s*0\\s*;[^}]*}[^}]*\\.page-break-before-module2\\s*\\{[^}]*break-before\\s*:\\s*page\\s*;[^}]*page-break-before\\s*:\\s*always\\s*;[^}]*height\\s*:\\s*0\\s*;[^}]*margin\\s*:\\s*0\\s*;[^}]*padding\\s*:\\s*0\\s*;[^}]*line-height\\s*:\\s*0\\s*;[^}]*font-size\\s*:\\s*0\\s*;[^}]*}")
                 .doesNotContainPattern("\\.analysis-grid\\s*\\{[^}]*display\\s*:\\s*grid\\s*;")
                 .doesNotContainPattern("\\.frequency-grid\\s*\\{[^}]*display\\s*:\\s*flex\\s*;")
                 .doesNotContainPattern("\\.student-case\\s*\\{[^}]*display\\s*:\\s*flex\\s*;")