|
@@ -13,16 +13,17 @@ import java.io.InputStream;
|
|
|
import java.io.UncheckedIOException;
|
|
import java.io.UncheckedIOException;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.time.Instant;
|
|
import java.time.Instant;
|
|
|
-import java.time.ZoneOffset;
|
|
|
|
|
-import java.time.format.DateTimeFormatter;
|
|
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
|
|
+import java.util.Locale;
|
|
|
import java.util.Objects;
|
|
import java.util.Objects;
|
|
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
|
|
|
@Component
|
|
@Component
|
|
|
public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintReportRenderer {
|
|
public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintReportRenderer {
|
|
|
|
|
|
|
|
- private static final DateTimeFormatter DATE_TIME_FORMATTER =
|
|
|
|
|
- DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
|
|
|
|
|
|
|
+ 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 final ObjectMapper objectMapper;
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
|
|
@@ -40,7 +41,6 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
try {
|
|
try {
|
|
|
OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
|
|
OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
|
|
|
return loadTemplate()
|
|
return loadTemplate()
|
|
|
- .replace("{{reportIntroShell}}", renderReportIntroShell(reportPayload, generatedAt))
|
|
|
|
|
.replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
|
|
.replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
|
|
|
.replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
|
|
.replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
|
|
|
.replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
|
|
.replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
|
|
@@ -55,83 +55,73 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private String loadTemplate() throws IOException {
|
|
private String loadTemplate() throws IOException {
|
|
|
- try (InputStream inputStream = new ClassPathResource("templates/outlook-exam-sprint-report-template.html").getInputStream()) {
|
|
|
|
|
|
|
+ try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
|
|
|
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
|
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private String renderReportIntroShell(OutlookExamSprintReportPayload payload, Instant generatedAt) {
|
|
|
|
|
- OutlookExamSprintReportPayload.ReportMetadata metadata = payload.reportMetadata();
|
|
|
|
|
- OutlookExamSprintReportPayload.ReadinessOverview readinessOverview = payload.readinessOverview();
|
|
|
|
|
-
|
|
|
|
|
- return new StringBuilder()
|
|
|
|
|
- .append("<div class='report-intro-shell'>")
|
|
|
|
|
- .append("<div class='report-intro-meta'>")
|
|
|
|
|
- .append("<span>报告版本:").append(escape(metadata.reportVersionLabel())).append("</span>")
|
|
|
|
|
- .append("<span>学生:").append(escape(metadata.learnerName())).append("</span>")
|
|
|
|
|
- .append("<span>目标考试:").append(escape(metadata.targetExamName())).append("</span>")
|
|
|
|
|
- .append("<span>冲刺周期:").append(escape(metadata.sprintPeriodLabel())).append("</span>")
|
|
|
|
|
- .append("<span>作者:").append(escape(metadata.authorName())).append("</span>")
|
|
|
|
|
- .append("<span>生成时间:").append(escape(DATE_TIME_FORMATTER.format(generatedAt))).append("</span>")
|
|
|
|
|
- .append("</div>")
|
|
|
|
|
- .append("<p class='report-intro-summary'>").append(escape(readinessOverview.summary())).append("</p>")
|
|
|
|
|
- .append("<p class='report-intro-insight'>核心观察:").append(escape(readinessOverview.keyInsight())).append("</p>")
|
|
|
|
|
- .append("<p class='report-intro-summary'>当前阶段:").append(escape(readinessOverview.currentStage())).append(" · 备考得分 ")
|
|
|
|
|
- .append(readinessOverview.readinessScore()).append("</p>")
|
|
|
|
|
- .append("</div>")
|
|
|
|
|
- .toString();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
private String renderSyllabusMasteryChart(OutlookExamSprintReportPayload.SyllabusMasteryChart chart) {
|
|
private String renderSyllabusMasteryChart(OutlookExamSprintReportPayload.SyllabusMasteryChart chart) {
|
|
|
- int masteredArcPercent = Math.max(0, Math.min(100, chart.masteryPercent()));
|
|
|
|
|
- int unmasteredArcPercent = 100 - masteredArcPercent;
|
|
|
|
|
|
|
+ double masteredPercent = percentage(chart.masteredWordCount(), chart.totalWordCount());
|
|
|
|
|
+ double unmasteredPercent = percentage(chart.unmasteredWordCount(), chart.totalWordCount());
|
|
|
|
|
+ double endAngle = -90 + (Math.max(0d, Math.min(100d, masteredPercent)) * 3.6);
|
|
|
|
|
|
|
|
return new StringBuilder()
|
|
return new StringBuilder()
|
|
|
- .append("<div class='card-title'>考纲词汇掌握情况</div>")
|
|
|
|
|
- .append("<div class='chart-shell'>")
|
|
|
|
|
|
|
+ .append("<div class='card'>")
|
|
|
|
|
+ .append("<h3 class='card-title'>考纲词汇掌握情况</h3>")
|
|
|
|
|
+ .append("<div class='chart-box'>")
|
|
|
.append("<svg class='syllabus-donut-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
|
|
.append("<svg class='syllabus-donut-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
|
|
|
- .append("<circle class='chart-track' cx='110' cy='110' r='76'></circle>")
|
|
|
|
|
- .append("<path class='donut-mastered-arc' d='")
|
|
|
|
|
- .append(describeArc(110, 110, 76, -90, -90 + (masteredArcPercent * 3.6)))
|
|
|
|
|
- .append("' stroke='#448aff' stroke-width='18' fill='none' stroke-linecap='round'/>")
|
|
|
|
|
|
|
+ .append("<circle class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'></circle>")
|
|
|
|
|
+ .append(renderProgressRing("donut-mastered-arc", "donut-mastered-full-circle", 110, 110, 76, masteredPercent, "#448aff"))
|
|
|
.append("<path class='donut-unmastered-arc' d='")
|
|
.append("<path class='donut-unmastered-arc' d='")
|
|
|
- .append(describeArc(110, 110, 76, -90 + (masteredArcPercent * 3.6), 270))
|
|
|
|
|
|
|
+ .append(describeArc(110, 110, 76, endAngle, 270))
|
|
|
.append("' stroke='#e8eef7' stroke-width='18' fill='none' stroke-linecap='round'/>")
|
|
.append("' stroke='#e8eef7' stroke-width='18' fill='none' stroke-linecap='round'/>")
|
|
|
- .append("<text class='chart-percent' x='110' y='106' text-anchor='middle'>")
|
|
|
|
|
- .append(chart.masteryPercent()).append("%</text>")
|
|
|
|
|
- .append("<text class='chart-caption' x='110' y='131' text-anchor='middle'>")
|
|
|
|
|
- .append(escape(chart.summaryLabel())).append("</text>")
|
|
|
|
|
|
|
+ .append("<text class='chart-percent' x='110' y='106' text-anchor='middle' fill='#2b4c8a' font-size='28' font-weight='700'>")
|
|
|
|
|
+ .append(formatTwoDecimals(masteredPercent)).append("%</text>")
|
|
|
|
|
+ .append("<text class='chart-caption' x='110' y='131' text-anchor='middle' fill='#5f6b7a' font-size='14'>掌握率</text>")
|
|
|
.append("</svg>")
|
|
.append("</svg>")
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
.append("<div class='data-text'>")
|
|
.append("<div class='data-text'>")
|
|
|
- .append("已掌握 ").append(chart.masteredWordCount()).append(" / ").append(chart.totalWordCount())
|
|
|
|
|
- .append(" 词,未掌握 ").append(chart.unmasteredWordCount()).append(" 词。</div>")
|
|
|
|
|
- .append("<p class='chart-note'>").append(escape(chart.recommendation())).append("</p>")
|
|
|
|
|
|
|
+ .append("考纲总量:<span class='highlight'>").append(chart.totalWordCount())
|
|
|
|
|
+ .append("词</span> | 已掌握:<span class='highlight'>").append(chart.masteredWordCount())
|
|
|
|
|
+ .append("词(").append(formatTwoDecimals(masteredPercent)).append("%)</span><br/>")
|
|
|
|
|
+ .append("未掌握:<span class='highlight'>").append(chart.unmasteredWordCount())
|
|
|
|
|
+ .append("词(").append(formatTwoDecimals(unmasteredPercent)).append("%)</span>")
|
|
|
|
|
+ .append("</div>")
|
|
|
|
|
+ .append("</div>")
|
|
|
.toString();
|
|
.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private String renderPastPaperVocabularyChart(OutlookExamSprintReportPayload.PastPaperVocabularyChart chart) {
|
|
private String renderPastPaperVocabularyChart(OutlookExamSprintReportPayload.PastPaperVocabularyChart chart) {
|
|
|
- int totalHeight = barHeight(chart.totalWordCount(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
|
|
|
|
|
- int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
|
|
|
|
|
|
|
+ int maxValue = Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint());
|
|
|
|
|
+ int totalHeight = barHeight(chart.totalWordCount(), maxValue);
|
|
|
|
|
+ int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), maxValue);
|
|
|
|
|
+ double beforePercent = percentage(chart.unknownWordCountBeforeSprint(), chart.totalWordCount());
|
|
|
|
|
+ double afterPercent = percentage(chart.unknownWordCountAfterSprint(), chart.totalWordCount());
|
|
|
|
|
|
|
|
return new StringBuilder()
|
|
return new StringBuilder()
|
|
|
- .append("<div class='card-title'>真题试卷词汇掌握情况</div>")
|
|
|
|
|
- .append("<div class='chart-shell'>")
|
|
|
|
|
|
|
+ .append("<div class='card'>")
|
|
|
|
|
+ .append("<h3 class='card-title'>真题试卷词汇掌握情况</h3>")
|
|
|
|
|
+ .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("<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, 220))
|
|
|
|
|
|
|
+ .append(renderChartAxes(320))
|
|
|
.append("<rect class='chart-column total-column' x='84' y='").append(180 - totalHeight)
|
|
.append("<rect class='chart-column total-column' x='84' y='").append(180 - totalHeight)
|
|
|
.append("' width='56' height='").append(totalHeight).append("' rx='8' ry='8' fill='#448aff'/>")
|
|
.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(180 - unknownHeight)
|
|
|
.append("' width='56' height='").append(unknownHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
|
|
.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='112' y='198' text-anchor='middle'>总词量</text>")
|
|
|
- .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>未知词</text>")
|
|
|
|
|
|
|
+ .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>生词量</text>")
|
|
|
.append("</svg>")
|
|
.append("</svg>")
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
- .append("<div class='data-text'>总词量 ").append(chart.totalWordCount())
|
|
|
|
|
- .append(" 词,冲刺前未知 ").append(chart.unknownWordCountBeforeSprint())
|
|
|
|
|
- .append(" 词,冲刺后未知 ").append(chart.unknownWordCountAfterSprint()).append(" 词。</div>")
|
|
|
|
|
- .append("<p class='chart-note'>").append(escape(chart.projectedScoreGainLabel())).append(" · ")
|
|
|
|
|
- .append(escape(chart.recommendation())).append("</p>")
|
|
|
|
|
|
|
+ .append("<div class='data-text'>")
|
|
|
|
|
+ .append("真题总词:").append(chart.totalWordCount()).append("词 | 生词量:")
|
|
|
|
|
+ .append(chart.unknownWordCountBeforeSprint()).append("词(")
|
|
|
|
|
+ .append(formatTwoDecimals(beforePercent)).append("%)<br/>")
|
|
|
|
|
+ .append("冲刺后生词:").append(chart.unknownWordCountAfterSprint())
|
|
|
|
|
+ .append("词,生词占比降至").append(formatTwoDecimals(afterPercent)).append("%,")
|
|
|
|
|
+ .append(escape(chart.projectedScoreGainLabel()))
|
|
|
|
|
+ .append("</div>")
|
|
|
|
|
+ .append("<p class='data-text'>").append(escape(chart.recommendation())).append("</p>")
|
|
|
|
|
+ .append("</div>")
|
|
|
.toString();
|
|
.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -140,145 +130,184 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
int highScoreHeight = barHeight(chart.highScorePercent(), 100);
|
|
int highScoreHeight = barHeight(chart.highScorePercent(), 100);
|
|
|
|
|
|
|
|
return new StringBuilder()
|
|
return new StringBuilder()
|
|
|
- .append("<div class='card-title'>常考词汇掌握情况</div>")
|
|
|
|
|
- .append("<div class='chart-shell'>")
|
|
|
|
|
|
|
+ .append("<div class='card'>")
|
|
|
|
|
+ .append("<h3 class='card-title'>常考词汇掌握情况</h3>")
|
|
|
|
|
+ .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("<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, 220))
|
|
|
|
|
|
|
+ .append(renderChartAxes(320))
|
|
|
.append("<rect class='chart-column basic-core-column' x='84' y='").append(180 - basicHeight)
|
|
.append("<rect class='chart-column basic-core-column' x='84' y='").append(180 - basicHeight)
|
|
|
.append("' width='56' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
|
|
.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(180 - highScoreHeight)
|
|
|
.append("' width='56' height='").append(highScoreHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
|
|
.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>")
|
|
|
|
|
|
|
+ .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>")
|
|
|
.append("</svg>")
|
|
.append("</svg>")
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
- .append("<div class='data-text'>基础核心 ").append(chart.basicCorePercent()).append("% ,高分词 ")
|
|
|
|
|
- .append(chart.highScorePercent()).append("%。</div>")
|
|
|
|
|
- .append("<p class='chart-note'>").append(escape(chart.highlightLabel())).append("</p>")
|
|
|
|
|
|
|
+ .append("<div class='data-text'>基础必会词:掌握率").append(chart.basicCorePercent())
|
|
|
|
|
+ .append("%<br/>高分拉分词:掌握率").append(chart.highScorePercent()).append("%</div>")
|
|
|
|
|
+ .append("<p class='data-text'><span class='highlight'>")
|
|
|
|
|
+ .append(escape(chart.highlightLabel()))
|
|
|
|
|
+ .append("</span></p>")
|
|
|
|
|
+ .append("</div>")
|
|
|
.toString();
|
|
.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private String renderVocabularyFrequencyBandChart(OutlookExamSprintReportPayload.VocabularyFrequencyBandChart chart) {
|
|
private String renderVocabularyFrequencyBandChart(OutlookExamSprintReportPayload.VocabularyFrequencyBandChart chart) {
|
|
|
List<OutlookExamSprintReportPayload.VocabularyFrequencyBar> bars = chart.bars();
|
|
List<OutlookExamSprintReportPayload.VocabularyFrequencyBar> bars = chart.bars();
|
|
|
- int highest = bars.stream().mapToInt(bar -> (int) Math.ceil(bar.currentValue())).max().orElse(1);
|
|
|
|
|
|
|
+ double highest = bars.stream()
|
|
|
|
|
+ .mapToDouble(OutlookExamSprintReportPayload.VocabularyFrequencyBar::currentValue)
|
|
|
|
|
+ .max()
|
|
|
|
|
+ .orElse(1d);
|
|
|
|
|
+ int maxScaled = (int) Math.ceil(highest * 10d);
|
|
|
|
|
|
|
|
StringBuilder builder = new StringBuilder();
|
|
StringBuilder builder = new StringBuilder();
|
|
|
- builder.append("<svg class='frequency-band-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>");
|
|
|
|
|
- builder.append(renderChartAxes(360, 220));
|
|
|
|
|
|
|
+ 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));
|
|
|
|
|
|
|
|
int[] xPositions = {70, 160, 250};
|
|
int[] xPositions = {70, 160, 250};
|
|
|
String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
|
|
String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
|
|
|
for (int index = 0; index < Math.min(3, bars.size()); index++) {
|
|
for (int index = 0; index < Math.min(3, bars.size()); index++) {
|
|
|
OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
|
|
OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
|
|
|
- int height = barHeight((int) Math.round(bar.currentValue()), highest);
|
|
|
|
|
|
|
+ int height = barHeight((int) Math.round(bar.currentValue() * 10d), maxScaled);
|
|
|
int x = xPositions[index];
|
|
int x = xPositions[index];
|
|
|
|
|
+
|
|
|
builder.append("<rect class='chart-column ").append(columnClasses[index]).append("' x='")
|
|
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(180 - height).append("' width='54' height='")
|
|
|
- .append(height).append("' rx='8' ry='8' fill='").append(escape(bar.themeColor())).append("'/>")
|
|
|
|
|
|
|
+ .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("<text class='donut-label-text' x='").append(x + 27).append("' y='198' text-anchor='middle'>")
|
|
|
.append(escape(bar.bandLabel())).append("</text>")
|
|
.append(escape(bar.bandLabel())).append("</text>")
|
|
|
.append("<text class='chart-caption' x='").append(x + 27).append("' y='20' text-anchor='middle'>")
|
|
.append("<text class='chart-caption' x='").append(x + 27).append("' y='20' text-anchor='middle'>")
|
|
|
.append(escape(bar.priorityLabel())).append("</text>");
|
|
.append(escape(bar.priorityLabel())).append("</text>");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- builder.append("</svg>");
|
|
|
|
|
|
|
+ builder.append("</svg>")
|
|
|
|
|
+ .append("</div>")
|
|
|
|
|
+ .append("<div class='data-text'>");
|
|
|
|
|
+
|
|
|
|
|
+ for (int index = 0; index < Math.min(3, bars.size()); index++) {
|
|
|
|
|
+ OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
|
|
|
|
|
+ if (index > 0) {
|
|
|
|
|
+ builder.append("<br/>");
|
|
|
|
|
+ }
|
|
|
|
|
+ builder.append(escape(bar.bandLabel())).append(":")
|
|
|
|
|
+ .append(formatOneDecimal(bar.currentValue()))
|
|
|
|
|
+ .append("(")
|
|
|
|
|
+ .append(escape(bar.priorityLabel()))
|
|
|
|
|
+ .append(")");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ builder.append("</div>")
|
|
|
|
|
+ .append("</div>");
|
|
|
return builder.toString();
|
|
return builder.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private String renderStudySuggestionSection(OutlookExamSprintReportPayload.FrequencyPlan frequencyPlan) {
|
|
private String renderStudySuggestionSection(OutlookExamSprintReportPayload.FrequencyPlan frequencyPlan) {
|
|
|
StringBuilder builder = new StringBuilder();
|
|
StringBuilder builder = new StringBuilder();
|
|
|
- builder.append("<div class='study-suggestion-shell'>")
|
|
|
|
|
- .append("<div class='study-suggestion-intro'>")
|
|
|
|
|
- .append("<h3>").append(escape(frequencyPlan.recommendationTitle())).append("</h3>")
|
|
|
|
|
- .append("<p>").append(escape(frequencyPlan.recommendationSummary())).append("</p>")
|
|
|
|
|
|
|
+ builder.append("<div class='suggest-item'>")
|
|
|
|
|
+ .append("<h4>🎯 练习学案频率与提分规划</h4>")
|
|
|
|
|
+ .append("<p class='text-desc'>")
|
|
|
|
|
+ .append(escape(frequencyPlan.recommendationSummary()))
|
|
|
|
|
+ .append("</p>")
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
- .append("<div class='study-frequency-grid'>");
|
|
|
|
|
-
|
|
|
|
|
- for (OutlookExamSprintReportPayload.FrequencyPlanCard card : frequencyPlan.cards()) {
|
|
|
|
|
- builder.append("<div class='study-frequency-card")
|
|
|
|
|
- .append(card.recommended() ? " active" : "")
|
|
|
|
|
- .append("'>")
|
|
|
|
|
- .append("<div class='study-frequency-header'>")
|
|
|
|
|
- .append(escape(card.cadencePerWeek() + "套/周"));
|
|
|
|
|
- if (card.emphasisIcon() != null && !card.emphasisIcon().isBlank()) {
|
|
|
|
|
- builder.append("<span class='crown'>").append(escape(card.emphasisIcon())).append("</span>");
|
|
|
|
|
|
|
+ .append("<table class='frequency-table' role='presentation'><tr class='frequency-row'>");
|
|
|
|
|
+
|
|
|
|
|
+ for (int index = 0; index < frequencyPlan.cards().size(); index++) {
|
|
|
|
|
+ OutlookExamSprintReportPayload.FrequencyPlanCard card = frequencyPlan.cards().get(index);
|
|
|
|
|
+ int columnNumber = index + 1;
|
|
|
|
|
+ builder.append("<td class='frequency-cell frequency-cell-").append(columnNumber).append("'>")
|
|
|
|
|
+ .append("<div class='freq-card card-").append(columnNumber);
|
|
|
|
|
+ if (card.recommended()) {
|
|
|
|
|
+ builder.append(" active");
|
|
|
}
|
|
}
|
|
|
- builder.append("</div>")
|
|
|
|
|
- .append("<div class='study-frequency-progress'><span class='study-frequency-progress-fill' style='width:")
|
|
|
|
|
- .append(card.winRatePercent()).append("%'></span></div>")
|
|
|
|
|
- .append("<div class='study-frequency-data'>")
|
|
|
|
|
- .append("<span class='win-rate'>").append(escape(card.scoreGainLabel())).append("</span> · ")
|
|
|
|
|
- .append(card.winRatePercent()).append("%")
|
|
|
|
|
- .append("</div>");
|
|
|
|
|
-
|
|
|
|
|
- if (card.badgeLabel() != null && !card.badgeLabel().isBlank()) {
|
|
|
|
|
- builder.append("<div class='badge'>").append(escape(card.badgeLabel())).append("</div>");
|
|
|
|
|
|
|
+ builder.append("'>")
|
|
|
|
|
+ .append("<div class='freq-header'>")
|
|
|
|
|
+ .append(card.cadencePerWeek()).append("套/周");
|
|
|
|
|
+
|
|
|
|
|
+ if (card.recommended()) {
|
|
|
|
|
+ String badgeLabel = card.badgeLabel() == null || card.badgeLabel().isBlank() ? "推荐" : card.badgeLabel();
|
|
|
|
|
+ builder.append(" <span class='badge'>").append(escape(badgeLabel)).append("</span>");
|
|
|
|
|
+ } else if (card.cadencePerWeek() == 5) {
|
|
|
|
|
+ builder.append(" <span class='crown'>★</span>");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- builder.append("</div>");
|
|
|
|
|
|
|
+ builder.append("</div>")
|
|
|
|
|
+ .append("<div class='freq-progress'><span style='width:")
|
|
|
|
|
+ .append(card.winRatePercent()).append("%'></span></div>")
|
|
|
|
|
+ .append("<div class='freq-data'>提升 <strong>")
|
|
|
|
|
+ .append(escape(card.scoreGainLabel()))
|
|
|
|
|
+ .append("</strong> · 胜率 <strong>")
|
|
|
|
|
+ .append(card.winRatePercent())
|
|
|
|
|
+ .append("%</strong></div>")
|
|
|
|
|
+ .append("</div>")
|
|
|
|
|
+ .append("</td>");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- builder.append("</div>")
|
|
|
|
|
- .append("<div class='study-strategy-note'><span class='study-strategy-label'>")
|
|
|
|
|
|
|
+ builder.append("</tr></table>")
|
|
|
|
|
+ .append("<div class='suggest-note'><strong>")
|
|
|
.append(escape(frequencyPlan.recommendationTitle()))
|
|
.append(escape(frequencyPlan.recommendationTitle()))
|
|
|
- .append("</span>")
|
|
|
|
|
|
|
+ .append(":</strong>")
|
|
|
.append(escape(frequencyPlan.recommendationSummary()))
|
|
.append(escape(frequencyPlan.recommendationSummary()))
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
- .append("<div class='study-stage-box'>");
|
|
|
|
|
|
|
+ .append("<div class='suggest-box'>");
|
|
|
|
|
|
|
|
for (OutlookExamSprintReportPayload.PhaseSuggestion suggestion : frequencyPlan.phaseSuggestions()) {
|
|
for (OutlookExamSprintReportPayload.PhaseSuggestion suggestion : frequencyPlan.phaseSuggestions()) {
|
|
|
- builder.append("<div class='study-stage-item'>")
|
|
|
|
|
|
|
+ builder.append("<div class='suggest-item'>")
|
|
|
.append("<h4>").append(escape(suggestion.title())).append("</h4>")
|
|
.append("<h4>").append(escape(suggestion.title())).append("</h4>")
|
|
|
.append("<p>").append(escape(suggestion.description())).append("</p>")
|
|
.append("<p>").append(escape(suggestion.description())).append("</p>")
|
|
|
.append("</div>");
|
|
.append("</div>");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- builder.append("</div></div>");
|
|
|
|
|
|
|
+ builder.append("</div>");
|
|
|
return builder.toString();
|
|
return builder.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private String renderScoreImprovementCaseStudy(OutlookExamSprintReportPayload.ScoreImprovementCaseStudy caseStudy) {
|
|
private String renderScoreImprovementCaseStudy(OutlookExamSprintReportPayload.ScoreImprovementCaseStudy caseStudy) {
|
|
|
|
|
+ double progressPercent = Math.max(0d, Math.min(100d, caseStudy.hitRatePercent()));
|
|
|
|
|
+ double endAngle = -90 + (progressPercent * 3.6);
|
|
|
|
|
+
|
|
|
return new StringBuilder()
|
|
return new StringBuilder()
|
|
|
- .append("<div class='case-study-shell'>")
|
|
|
|
|
- .append("<div class='case-study-visual'>")
|
|
|
|
|
- .append("<svg class='case-study-visual-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
|
|
|
|
|
- .append("<circle cx='130' cy='130' r='92' fill='#fff3e5' stroke='#f3d7bb' stroke-width='2'/>")
|
|
|
|
|
- .append("<path d='M52 176 L104 124 L136 146 L204 82' fill='none' stroke='#ff7d00' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/>")
|
|
|
|
|
- .append("<circle cx='52' cy='176' r='8' fill='#ff7d00'/>")
|
|
|
|
|
- .append("<circle cx='104' cy='124' r='8' fill='#ff7d00'/>")
|
|
|
|
|
- .append("<circle cx='136' cy='146' r='8' fill='#ff7d00'/>")
|
|
|
|
|
- .append("<circle cx='204' cy='82' r='8' fill='#ff7d00'/>")
|
|
|
|
|
- .append("<text x='130' y='214' text-anchor='middle' fill='#8a5d36' font-size='14'>")
|
|
|
|
|
- .append(escape(caseStudy.baselineScoreLabel())).append(" → ").append(caseStudy.finalScore()).append("分</text>")
|
|
|
|
|
|
|
+ .append("<div class='student-case'>")
|
|
|
|
|
+ .append("<table class='student-case-table' role='presentation'>")
|
|
|
|
|
+ .append("<tr class='student-case-row'>")
|
|
|
|
|
+ .append("<td class='case-chart-cell'>")
|
|
|
|
|
+ .append("<div class='case-chart'>")
|
|
|
|
|
+ .append("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
|
|
|
|
|
+ .append("<circle cx='130' cy='130' r='86' fill='none' stroke='#ffe3c7' stroke-width='18'></circle>")
|
|
|
|
|
+ .append(renderProgressRing("case-progress-arc", "case-progress-full-circle", 130, 130, 86, progressPercent, "#ff7d00"))
|
|
|
|
|
+ .append("<text x='130' y='124' text-anchor='middle' fill='#8a5d36' font-size='26' font-weight='700'>")
|
|
|
|
|
+ .append(formatOneDecimal(progressPercent)).append("%</text>")
|
|
|
|
|
+ .append("<text x='130' y='152' text-anchor='middle' fill='#8a5d36' font-size='12'>命中率</text>")
|
|
|
.append("</svg>")
|
|
.append("</svg>")
|
|
|
- .append("<div class='case-hit-rate-badge'><span class='case-hit-rate-value'>")
|
|
|
|
|
- .append(caseStudy.hitRatePercent()).append("%</span><span class='case-hit-rate-label'>命中率</span></div>")
|
|
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
|
|
+ .append("</td>")
|
|
|
|
|
+ .append("<td class='case-info-cell'>")
|
|
|
.append("<div class='case-info'>")
|
|
.append("<div class='case-info'>")
|
|
|
.append("<h3>").append(escape(caseStudy.headline())).append("</h3>")
|
|
.append("<h3>").append(escape(caseStudy.headline())).append("</h3>")
|
|
|
- .append(caseInfoSection("背景", caseStudy.studyPeriodLabel()))
|
|
|
|
|
- .append(caseInfoSection("学生", caseStudy.learnerName()))
|
|
|
|
|
- .append(caseInfoSection("记忆词汇", caseStudy.memorizedWordCount() + "词"))
|
|
|
|
|
- .append(caseInfoSection("高考命中", caseStudy.examHitWordCount() + "词"))
|
|
|
|
|
- .append(caseInfoSection("结果", caseStudy.baselineScoreLabel() + " · " + caseStudy.finalScore() + "分 · 提升分数:+" + caseStudy.scoreGain() + "分"))
|
|
|
|
|
|
|
+ .append("<p><strong>学员:</strong>").append(escape(caseStudy.learnerName())).append("</p>")
|
|
|
|
|
+ .append("<p><strong>阶段:</strong>").append(escape(caseStudy.studyPeriodLabel())).append("</p>")
|
|
|
|
|
+ .append("<p><strong>记忆词汇:</strong>").append(caseStudy.memorizedWordCount())
|
|
|
|
|
+ .append("词 | 高考命中:").append(caseStudy.examHitWordCount())
|
|
|
|
|
+ .append("词 | 命中率:").append(formatOneDecimal(progressPercent)).append("%</p>")
|
|
|
|
|
+ .append("<p><strong>提升分数:</strong><span class='highlight score-gain'>+")
|
|
|
|
|
+ .append(caseStudy.scoreGain()).append("分</span></p>")
|
|
|
|
|
+ .append("<p>").append(escape(caseStudy.baselineScoreLabel())).append(" → ")
|
|
|
|
|
+ .append(caseStudy.finalScore()).append("分</p>")
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
|
|
+ .append("</td>")
|
|
|
|
|
+ .append("</tr>")
|
|
|
|
|
+ .append("</table>")
|
|
|
.append("</div>")
|
|
.append("</div>")
|
|
|
.toString();
|
|
.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private StringBuilder caseInfoSection(String label, String value) {
|
|
|
|
|
- return new StringBuilder()
|
|
|
|
|
- .append("<div class='case-info-section'>")
|
|
|
|
|
- .append("<div class='case-info-group'><span class='case-info-label'>")
|
|
|
|
|
- .append(escape(label)).append(":</span><span class='case-info-value'>")
|
|
|
|
|
- .append(escape(value)).append("</span></div>")
|
|
|
|
|
- .append("</div>");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private String renderChartAxes(int width, int height) {
|
|
|
|
|
|
|
+ private String renderChartAxes(int width) {
|
|
|
return new StringBuilder()
|
|
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='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='34' y1='180' x2='").append(width - 24)
|
|
|
|
|
+ .append("' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
|
|
|
.toString();
|
|
.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -290,6 +319,13 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
return Math.max(16, (int) Math.round(ratio * 130));
|
|
return Math.max(16, (int) Math.round(ratio * 130));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private double percentage(int numerator, int denominator) {
|
|
|
|
|
+ if (denominator <= 0) {
|
|
|
|
|
+ return 0d;
|
|
|
|
|
+ }
|
|
|
|
|
+ return (numerator * 100d) / denominator;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private String describeArc(double centerX, double centerY, double radius, double startAngle, double endAngle) {
|
|
private String describeArc(double centerX, double centerY, double radius, double startAngle, double endAngle) {
|
|
|
double startRadians = Math.toRadians(startAngle);
|
|
double startRadians = Math.toRadians(startAngle);
|
|
|
double endRadians = Math.toRadians(endAngle);
|
|
double endRadians = Math.toRadians(endAngle);
|
|
@@ -297,20 +333,53 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
double startY = centerY + (radius * Math.sin(startRadians));
|
|
double startY = centerY + (radius * Math.sin(startRadians));
|
|
|
double endX = centerX + (radius * Math.cos(endRadians));
|
|
double endX = centerX + (radius * Math.cos(endRadians));
|
|
|
double endY = centerY + (radius * Math.sin(endRadians));
|
|
double endY = centerY + (radius * Math.sin(endRadians));
|
|
|
- double largeArcFlag = Math.abs(endAngle - startAngle) <= 180 ? 0 : 1;
|
|
|
|
|
|
|
+ int largeArcFlag = Math.abs(endAngle - startAngle) <= 180 ? 0 : 1;
|
|
|
|
|
+ return new StringBuilder()
|
|
|
|
|
+ .append("M ").append(formatTwoDecimals(startX)).append(' ')
|
|
|
|
|
+ .append(formatTwoDecimals(startY)).append(' ')
|
|
|
|
|
+ .append("A ").append(formatTwoDecimals(radius)).append(' ')
|
|
|
|
|
+ .append(formatTwoDecimals(radius)).append(" 0 ")
|
|
|
|
|
+ .append(largeArcFlag).append(" 1 ")
|
|
|
|
|
+ .append(formatTwoDecimals(endX)).append(' ')
|
|
|
|
|
+ .append(formatTwoDecimals(endY))
|
|
|
|
|
+ .toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String renderProgressRing(String arcClass,
|
|
|
|
|
+ String fullCircleClass,
|
|
|
|
|
+ double centerX,
|
|
|
|
|
+ double centerY,
|
|
|
|
|
+ double radius,
|
|
|
|
|
+ double percent,
|
|
|
|
|
+ String strokeColor) {
|
|
|
|
|
+ double boundedPercent = Math.max(0d, Math.min(100d, percent));
|
|
|
|
|
+ if (boundedPercent >= 100d) {
|
|
|
|
|
+ return new StringBuilder()
|
|
|
|
|
+ .append("<circle class='").append(fullCircleClass).append("' cx='")
|
|
|
|
|
+ .append(formatTwoDecimals(centerX)).append("' cy='")
|
|
|
|
|
+ .append(formatTwoDecimals(centerY)).append("' r='")
|
|
|
|
|
+ .append(formatTwoDecimals(radius)).append("' fill='none' stroke='")
|
|
|
|
|
+ .append(strokeColor).append("' stroke-width='18'></circle>")
|
|
|
|
|
+ .toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (boundedPercent <= 0d) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ double endAngle = -90 + (boundedPercent * 3.6);
|
|
|
return new StringBuilder()
|
|
return new StringBuilder()
|
|
|
- .append("M ").append(formatDouble(startX)).append(' ')
|
|
|
|
|
- .append(formatDouble(startY)).append(' ')
|
|
|
|
|
- .append("A ").append(formatDouble(radius)).append(' ')
|
|
|
|
|
- .append(formatDouble(radius)).append(" 0 ")
|
|
|
|
|
- .append((int) largeArcFlag).append(" 1 ")
|
|
|
|
|
- .append(formatDouble(endX)).append(' ')
|
|
|
|
|
- .append(formatDouble(endY))
|
|
|
|
|
|
|
+ .append("<path class='").append(arcClass).append("' d='")
|
|
|
|
|
+ .append(describeArc(centerX, centerY, radius, -90, endAngle))
|
|
|
|
|
+ .append("' stroke='").append(strokeColor)
|
|
|
|
|
+ .append("' stroke-width='18' fill='none' stroke-linecap='round'/>")
|
|
|
.toString();
|
|
.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private String formatDouble(double value) {
|
|
|
|
|
- return String.format(java.util.Locale.ROOT, "%.2f", value);
|
|
|
|
|
|
|
+ private String formatOneDecimal(double value) {
|
|
|
|
|
+ return String.format(Locale.ROOT, "%.1f", value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String formatTwoDecimals(double value) {
|
|
|
|
|
+ return String.format(Locale.ROOT, "%.2f", value);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private String escape(String value) {
|
|
private String escape(String value) {
|
|
@@ -323,4 +392,15 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
|
|
|
.replace("\"", """)
|
|
.replace("\"", """)
|
|
|
.replace("'", "'");
|
|
.replace("'", "'");
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private String safeColor(String value) {
|
|
|
|
|
+ if (value == null) {
|
|
|
|
|
+ return DEFAULT_THEME_COLOR;
|
|
|
|
|
+ }
|
|
|
|
|
+ String trimmed = value.trim();
|
|
|
|
|
+ if (HEX_COLOR_PATTERN.matcher(trimmed).matches()) {
|
|
|
|
|
+ return trimmed;
|
|
|
|
|
+ }
|
|
|
|
|
+ return DEFAULT_THEME_COLOR;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|