소스 검색

feat(exam-sprint): 优化成果报告模块排布与配色

金逸霄 2 주 전
부모
커밋
136bd7cf20

+ 5 - 4
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilder.java

@@ -16,8 +16,8 @@ public class AchievementExamSprintReportSvgChartBuilder {
                                      String beforeText,
                                      String afterLabel,
                                      double afterValue,
-                                      String afterText,
-                                      String fillColor) {
+                                     String afterText,
+                                     String fillColor) {
         double maxValue = Math.max(Math.max(beforeValue, afterValue), 1d);
         int axisLeft = 58;
         int axisBottom = 180;
@@ -29,6 +29,7 @@ public class AchievementExamSprintReportSvgChartBuilder {
         int plotHeight = axisBottom - axisTop;
         int beforeHeight = barHeight(beforeValue, maxValue, plotHeight);
         int afterHeight = barHeight(afterValue, maxValue, plotHeight);
+        String barFillColor = safeColor(fillColor);
 
         return new StringBuilder()
                 .append("<svg class='achievement-bar-chart ").append(safeCssClass(cssClass))
@@ -38,11 +39,11 @@ public class AchievementExamSprintReportSvgChartBuilder {
                 .append("<rect class='chart-bar chart-bar-before' x='").append(beforeX)
                 .append("' y='").append(axisBottom - beforeHeight)
                 .append("' width='").append(barWidth).append("' height='").append(beforeHeight)
-                .append("' rx='10' ry='10' fill='#9fb3c8'/>")
+                .append("' rx='10' ry='10' fill='").append(barFillColor).append("'/>")
                 .append("<rect class='chart-bar chart-bar-after' x='").append(afterX)
                 .append("' y='").append(axisBottom - afterHeight)
                 .append("' width='").append(barWidth).append("' height='").append(afterHeight)
-                .append("' rx='10' ry='10' fill='").append(safeColor(fillColor)).append("'/>")
+                .append("' rx='10' ry='10' fill='").append(barFillColor).append("'/>")
                 .append("<text class='chart-value' x='").append(beforeX + barWidth / 2)
                 .append("' y='").append(Math.max(18, axisBottom - 10 - beforeHeight))
                 .append("' text-anchor='middle'>").append(escape(beforeText)).append("</text>")

+ 2 - 2
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java

@@ -124,7 +124,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
                 "训练后",
                 safeNonNegativeFinite(comparison.afterValue()),
                 comparison.afterText(),
-                "#ff7d00"
+                "#448aff"
         );
     }
 
@@ -138,7 +138,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
                 "训练后",
                 safeNonNegativeFinite(comparison.afterValue()),
                 comparison.afterText(),
-                "#3f8cff"
+                "#34a853"
         );
     }
 

+ 35 - 42
abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html

@@ -115,37 +115,38 @@
             font-weight: 700;
         }
 
-        .comparison-table {
-            width: 100%;
-            table-layout: fixed;
-            border-collapse: collapse;
+        .comparison-section {
+            margin-top: 0;
+            margin-bottom: 50px;
         }
 
-        .chart-cell,
-        .detail-cell {
-            width: 50%;
-            vertical-align: middle;
-            background: #fff;
-            border: 1px solid #e7edf5;
+        .comparison-section .section-title {
+            margin: 0 0 25px;
         }
 
-        .chart-cell {
-            border-right: 0;
-            border-radius: 12px 0 0 12px;
-            padding: 16px;
-        }
-
-        .detail-cell {
-            border-radius: 0 12px 12px 0;
-            padding: 18px;
+        .card {
+            background: #fafbfc;
+            border: 1px solid #eaeef5;
+            border-radius: 10px;
+            padding: 25px;
+            page-break-inside: avoid;
         }
 
         .chart-box {
-            height: 210px;
+            width: 100%;
+            height: 260px;
+            margin: 10px 0;
             background: #f8fbff;
             border-radius: 10px;
         }
 
+        .data-text {
+            color: #444;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 14px;
+            line-height: 1.8;
+        }
+
         .chart-box svg {
             display: block;
             width: 100%;
@@ -273,32 +274,24 @@
         </tr>
     </table>
 
-    <div class="section">
+    <div class="section comparison-section">
         <h2 class="section-title">模块一:词汇量对比</h2>
-        <table class="comparison-table" role="presentation">
-            <tr>
-                <td class="chart-cell"><div class="chart-box">{{vocabularyComparisonChart}}</div></td>
-                <td class="detail-cell">
-                    <p class="detail-text">训练前词汇量:<span class="highlight">{{vocabularyBeforeText}}</span></p>
-                    <p class="detail-text">训练后词汇量:<span class="highlight">{{vocabularyAfterText}}</span></p>
-                    <p class="detail-text">本次提升:<span class="highlight">{{vocabularyGrowthDetailText}}</span></p>
-                </td>
-            </tr>
-        </table>
+        <div class="card">
+            <div class="chart-box">{{vocabularyComparisonChart}}</div>
+            <div class="data-text">训练前词汇量:<span class="highlight">{{vocabularyBeforeText}}</span><br/>
+                训练后词汇量:<span class="highlight">{{vocabularyAfterText}}</span><br/>
+                本次提升:<span class="highlight">{{vocabularyGrowthDetailText}}</span></div>
+        </div>
     </div>
 
-    <div class="section">
+    <div class="section comparison-section">
         <h2 class="section-title">模块二:试卷熟词量对比</h2>
-        <table class="comparison-table" role="presentation">
-            <tr>
-                <td class="chart-cell"><div class="chart-box">{{paperKnownWordsComparisonChart}}</div></td>
-                <td class="detail-cell">
-                    <p class="detail-text">训练前熟词量:<span class="highlight">{{paperKnownWordsBeforeText}}</span></p>
-                    <p class="detail-text">训练后熟词量:<span class="highlight">{{paperKnownWordsAfterText}}</span></p>
-                    <p class="detail-text">本次提升:<span class="highlight">{{paperKnownWordsGrowthDetailText}}</span></p>
-                </td>
-            </tr>
-        </table>
+        <div class="card">
+            <div class="chart-box">{{paperKnownWordsComparisonChart}}</div>
+            <div class="data-text">训练前熟词量:<span class="highlight">{{paperKnownWordsBeforeText}}</span><br/>
+                训练后熟词量:<span class="highlight">{{paperKnownWordsAfterText}}</span><br/>
+                本次提升:<span class="highlight">{{paperKnownWordsGrowthDetailText}}</span></div>
+        </div>
     </div>
 
     <div class="section">

+ 30 - 5
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilderTest.java

@@ -2,10 +2,15 @@ package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.a
 
 import org.junit.jupiter.api.Test;
 
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 class AchievementExamSprintReportSvgChartBuilderTest {
 
+    private static final Pattern SELF_CLOSING_RECT_PATTERN = Pattern.compile("<rect\\b[^>]*/>");
+
     @Test
     void comparisonBarChartKeepsTinyPositiveBarsProportionalToAxisScale() {
         AchievementExamSprintReportSvgChartBuilder builder = new AchievementExamSprintReportSvgChartBuilder();
@@ -19,11 +24,31 @@ class AchievementExamSprintReportSvgChartBuilderTest {
                 "训练后",
                 1000,
                 "1000 词",
-                "#ff7d00");
+                "#448aff");
+
+        String beforeBar = extractRect(svg, "chart-bar chart-bar-before");
+        String afterBar = extractRect(svg, "chart-bar chart-bar-after");
+
+        assertThat(beforeBar)
+                .contains("height='0'")
+                .contains("fill='#448aff'")
+                .doesNotContain("fill='#9fb3c8'")
+                .doesNotContain("height='18'");
+        assertThat(afterBar)
+                .contains("height='144'")
+                .contains("fill='#448aff'");
+    }
 
-        assertThat(svg)
-                .contains("<rect class='chart-bar chart-bar-before' x='118' y='180' width='58' height='0' rx='10' ry='10' fill='#9fb3c8'/>")
-                .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#ff7d00'/>")
-                .doesNotContain("<rect class='chart-bar chart-bar-before' x='118' y='162' width='58' height='18'");
+    private String extractRect(String svgOrHtml, String rectClass) {
+        Matcher rectMatcher = SELF_CLOSING_RECT_PATTERN.matcher(svgOrHtml);
+        String singleQuotedClass = "class='" + rectClass + "'";
+        String doubleQuotedClass = "class=\"" + rectClass + "\"";
+        while (rectMatcher.find()) {
+            String rect = rectMatcher.group();
+            if (rect.contains(singleQuotedClass) || rect.contains(doubleQuotedClass)) {
+                return rect;
+            }
+        }
+        throw new AssertionError("rect should exist for class '" + rectClass + "' in SVG/HTML fragment:\n" + svgOrHtml);
     }
 }

+ 208 - 8
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java

@@ -7,12 +7,15 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.junit.jupiter.api.Test;
 
 import java.time.Instant;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
 class ClasspathAchievementExamSprintReportRendererTest {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Pattern SELF_CLOSING_RECT_PATTERN = Pattern.compile("<rect\\b[^>]*/>");
 
     @Test
     void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
@@ -20,6 +23,37 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
         String html = renderer.render(samplePayload(), Instant.parse("2026-04-25T08:00:00Z"));
 
+        assertSectionClassForTitle(html, "模块一:词汇量对比", "section comparison-section");
+        assertSectionClassForTitle(html, "模块二:试卷熟词量对比", "section comparison-section");
+        assertSectionClassForTitle(html, "模块三:实考生词命中状况", "section");
+
+        assertModuleUsesVerticalCard(
+                html,
+                "<h2 class=\"section-title\">模块一:词汇量对比</h2>",
+                "vocabulary-growth-chart",
+                "训练前词汇量:<span class=\"highlight\">2328 词</span><br/>",
+                "训练后词汇量:<span class=\"highlight\">2347 词</span><br/>",
+                "本次提升:<span class=\"highlight\">+19 词</span>");
+        assertModuleUsesVerticalCard(
+                html,
+                "<h2 class=\"section-title\">模块二:试卷熟词量对比</h2>",
+                "paper-known-words-chart",
+                "训练前熟词量:<span class=\"highlight\">650 个</span><br/>",
+                "训练后熟词量:<span class=\"highlight\">654 个</span><br/>",
+                "本次提升:<span class=\"highlight\">+4 个</span>");
+        assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-before", "#448aff");
+        assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-after", "#448aff");
+        assertBarFill(extractChartSvg(html, "paper-known-words-chart"), "chart-bar chart-bar-before", "#34a853");
+        assertBarFill(extractChartSvg(html, "paper-known-words-chart"), "chart-bar chart-bar-after", "#34a853");
+
+        assertCssRuleContains(html, ".section", "margin-top: 24px;", "page-break-inside: avoid;");
+        assertCssRuleContains(html, ".section-title", "margin: 0 0 14px;");
+        assertCssRuleContains(html, ".comparison-section", "margin-top: 0;", "margin-bottom: 50px;");
+        assertCssRuleContains(html, ".comparison-section .section-title", "margin: 0 0 25px;");
+        assertCssRuleContains(html, ".card", "background: #fafbfc;", "padding: 25px;");
+        assertCssRuleContains(html, ".chart-box", "height: 260px;");
+        assertCssRuleContains(html, ".data-text", "line-height: 1.8;");
+
         assertThat(html)
                 .contains("高考英语临考突击学习成果报告")
                 .contains("2024真题 · 两周专项训练 · 真实提分效果")
@@ -46,19 +80,15 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains(">0</text>")
                 .contains(">2347</text>")
                 .contains(">654</text>")
-                .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#ff7d00'/>")
-                .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#3f8cff'/>")
-                .contains("训练前词汇量:<span class=\"highlight\">2328 词</span>")
-                .contains("训练后词汇量:<span class=\"highlight\">2347 词</span>")
-                .contains("本次提升:<span class=\"highlight\">+19 词</span>")
-                .contains("训练前熟词量:<span class=\"highlight\">650 个</span>")
-                .contains("训练后熟词量:<span class=\"highlight\">654 个</span>")
                 .contains("成功减少生词:<span class=\"highlight\">4 个</span>")
                 .contains("class=\"word-list\"")
                 .contains("class=\"word-item\">number</div>")
                 .doesNotContain("cdn.jsdelivr.net")
                 .doesNotContain("echarts")
-                .doesNotContain("<script");
+                .doesNotContain("<script")
+                .doesNotContain("comparison-table")
+                .doesNotContain("chart-cell")
+                .doesNotContain("detail-cell");
 
         assertThat(countOccurrences(html, "class='chart-grid-line'")).isEqualTo(6);
         assertThat(countOccurrences(html, "class='chart-tick-label'")).isEqualTo(6);
@@ -187,6 +217,176 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 """);
     }
 
+    private void assertModuleUsesVerticalCard(
+            String html,
+            String sectionTitle,
+            String chartClass,
+            String beforeLine,
+            String afterLine,
+            String growthLine) {
+        int sectionStart = html.indexOf(sectionTitle);
+        assertThat(sectionStart)
+                .as("module section should exist: %s", sectionTitle)
+                .isGreaterThanOrEqualTo(0);
+
+        int nextSectionStart = html.indexOf("<h2 class=\"section-title\">", sectionStart + sectionTitle.length());
+        String sectionHtml = nextSectionStart >= 0
+                ? html.substring(sectionStart, nextSectionStart)
+                : html.substring(sectionStart);
+        String chartBox = "<div class=\"chart-box\">";
+        String svgClass = "class='achievement-bar-chart " + chartClass + "'";
+
+        assertThat(sectionHtml)
+                .as("%s should render as card -> chart-box -> data-text", sectionTitle)
+                .containsSubsequence(
+                        sectionTitle,
+                        "<div class=\"card\">",
+                        chartBox,
+                        svgClass,
+                        "</div>",
+                        "<div class=\"data-text\">",
+                        beforeLine,
+                        afterLine,
+                        growthLine);
+
+        int cardIndex = sectionHtml.indexOf("<div class=\"card\">");
+        int chartBoxIndex = sectionHtml.indexOf(chartBox, Math.max(cardIndex, 0));
+        int svgClassIndex = sectionHtml.indexOf(svgClass, Math.max(chartBoxIndex, 0));
+        int chartBoxCloseIndex = sectionHtml.indexOf("</div>", Math.max(svgClassIndex, 0));
+        int dataTextIndex = sectionHtml.indexOf("<div class=\"data-text\">", Math.max(chartBoxCloseIndex, 0));
+        assertThat(cardIndex)
+                .as("%s should contain card", sectionTitle)
+                .isGreaterThanOrEqualTo(0);
+        assertThat(chartBoxIndex)
+                .as("%s should contain chart-box inside card", sectionTitle)
+                .isGreaterThan(cardIndex);
+        assertThat(svgClassIndex)
+                .as("%s should contain the expected chart inside chart-box", sectionTitle)
+                .isGreaterThan(chartBoxIndex);
+        assertThat(chartBoxCloseIndex)
+                .as("%s should close chart-box before data-text", sectionTitle)
+                .isGreaterThan(svgClassIndex);
+        assertThat(dataTextIndex)
+                .as("%s should place data-text after chart-box", sectionTitle)
+                .isGreaterThan(chartBoxCloseIndex);
+    }
+
+    private String extractChartSvg(String html, String chartClass) {
+        int chartClassIndex = html.indexOf(chartClass);
+        assertThat(chartClassIndex)
+                .as("chart should exist: %s", chartClass)
+                .isGreaterThanOrEqualTo(0);
+
+        int svgStart = html.lastIndexOf("<svg", chartClassIndex);
+        int svgEnd = html.indexOf("</svg>", chartClassIndex);
+        assertThat(svgStart)
+                .as("chart should start with svg: %s", chartClass)
+                .isGreaterThanOrEqualTo(0);
+        assertThat(svgEnd)
+                .as("chart should close svg: %s", chartClass)
+                .isGreaterThan(svgStart);
+        return html.substring(svgStart, svgEnd + "</svg>".length());
+    }
+
+    private void assertSectionClassForTitle(String html, String title, String expectedClass) {
+        String sectionTitle = "<h2 class=\"section-title\">" + title + "</h2>";
+        int titleIndex = html.indexOf(sectionTitle);
+        assertThat(titleIndex)
+                .as("section title should exist: %s", title)
+                .isGreaterThanOrEqualTo(0);
+
+        int sectionStart = html.lastIndexOf("<div class=\"", titleIndex);
+        int classEnd = html.indexOf("\">", sectionStart);
+        assertThat(sectionStart)
+                .as("section wrapper should exist before title: %s", title)
+                .isGreaterThanOrEqualTo(0);
+        assertThat(classEnd)
+                .as("section wrapper should close before title: %s", title)
+                .isGreaterThan(sectionStart);
+        assertThat(html.substring(sectionStart, classEnd + "\">".length()))
+                .as("section wrapper class for %s", title)
+                .isEqualTo("<div class=\"" + expectedClass + "\">");
+    }
+
+    private void assertCssRuleContains(String html, String selector, String... declarations) {
+        String rule = extractCssRule(html, selector);
+        assertThat(rule)
+                .as("CSS rule for %s", selector)
+                .contains(declarations);
+    }
+
+    private String extractCssRule(String html, String selector) {
+        String css = extractCssText(html);
+        int searchFrom = 0;
+        while (searchFrom < css.length()) {
+            int selectorIndex = css.indexOf(selector, searchFrom);
+            if (selectorIndex < 0) {
+                break;
+            }
+
+            int braceStart = css.indexOf('{', selectorIndex + selector.length());
+            if (braceStart < 0) {
+                break;
+            }
+
+            boolean startsRule = isRuleStart(css, selectorIndex);
+            boolean selectorMatchesExactly = css.substring(selectorIndex + selector.length(), braceStart).trim().isEmpty();
+            if (startsRule && selectorMatchesExactly) {
+                int braceEnd = css.indexOf('}', braceStart);
+                assertThat(braceEnd)
+                        .as("CSS rule for %s should close", selector)
+                        .isGreaterThan(braceStart);
+                return css.substring(selectorIndex, braceEnd + 1);
+            }
+
+            searchFrom = selectorIndex + selector.length();
+        }
+
+        throw new AssertionError("CSS rule should exist for selector '" + selector + "' in style block:\n" + css);
+    }
+
+    private String extractCssText(String html) {
+        int styleStart = html.indexOf("<style>");
+        assertThat(styleStart)
+                .as("HTML should contain style block")
+                .isGreaterThanOrEqualTo(0);
+
+        int styleContentStart = styleStart + "<style>".length();
+        int styleEnd = html.indexOf("</style>", styleContentStart);
+        assertThat(styleEnd)
+                .as("HTML style block should close")
+                .isGreaterThan(styleContentStart);
+        return html.substring(styleContentStart, styleEnd);
+    }
+
+    private boolean isRuleStart(String css, int selectorIndex) {
+        int previous = selectorIndex - 1;
+        while (previous >= 0 && Character.isWhitespace(css.charAt(previous))) {
+            previous--;
+        }
+        return previous < 0 || css.charAt(previous) == '}';
+    }
+
+    private void assertBarFill(String svg, String rectClass, String expectedFill) {
+        String rect = extractRect(svg, rectClass);
+        assertThat(rect)
+                .as("%s should use %s", rectClass, expectedFill)
+                .contains("fill='" + expectedFill + "'");
+    }
+
+    private String extractRect(String svgOrHtml, String rectClass) {
+        Matcher rectMatcher = SELF_CLOSING_RECT_PATTERN.matcher(svgOrHtml);
+        String singleQuotedClass = "class='" + rectClass + "'";
+        String doubleQuotedClass = "class=\"" + rectClass + "\"";
+        while (rectMatcher.find()) {
+            String rect = rectMatcher.group();
+            if (rect.contains(singleQuotedClass) || rect.contains(doubleQuotedClass)) {
+                return rect;
+            }
+        }
+        throw new AssertionError("rect should exist for class '" + rectClass + "' in SVG/HTML fragment:\n" + svgOrHtml);
+    }
+
     private int countOccurrences(String value, String substring) {
         int count = 0;
         int index = 0;