添加试卷

This commit is contained in:
2026-05-03 16:16:44 +08:00 Unverified
parent df20ef9631
commit fabb5c1399
12 changed files with 2319 additions and 1 deletions
+781
View File
@@ -0,0 +1,781 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { examIntro, examMath, examScoring, examText } from "../data/exam";
import { site } from "../data/site";
const lines = examText
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const isSectionLine = (line: string) =>
/^(一、|二、|三、|四、|五、|六、|Ⅶ\.)/.test(line);
const imagePattern = /^\[image:(.+?):(.+?)\]$/;
const galleryPattern = /^\[gallery:(.+)\]$/;
const questionPattern = /^\[q\](.+)$/;
const mathPattern = /\[math:([\w-]+)\]/g;
const optionPattern = /^[A-D]\./;
const renderLine = (line: string) =>
line.replace(mathPattern, (_, id: string) => examMath[id] ?? "");
const getOptions = (line: string) => {
if (!optionPattern.test(line)) {
return [];
}
return line.match(/[A-D]\.\s*.*?(?=\s*[A-D]\.|$)/g) ?? [];
};
const getOptionValue = (option: string) => option.match(/^([A-D])\./)?.[1] ?? "";
const fillQuestionMap = new Map(examScoring.fillQuestions.map((question) => [question.number, question]));
const circledNumbers = ["①", "②", "③", "④", "⑤"];
const renderQuestion13Line = (line: string) =>
line.replace(/([①②③④⑤])▲/g, (_, marker: string) => {
const blankIndex = circledNumbers.indexOf(marker);
return `${marker}<input class="inline-fill-input" data-fill-answer data-question-number="13" data-blank-index="${blankIndex}" aria-label="第 13 题第 ${blankIndex + 1} 空" />`;
});
const objectiveMax =
examScoring.choiceQuestions.reduce((sum, question) => sum + question.points, 0) +
examScoring.fillQuestions
.filter((question) => question.mode !== "unordered-names")
.reduce((sum, question) => sum + question.points, 0) +
examScoring.choiceBonus.points;
const nameQuestionMax =
examScoring.fillQuestions.find((question) => question.mode === "unordered-names")?.points ?? 0;
const manualMax = examScoring.manualQuestions.reduce((sum, question) => sum + question.points, 0);
const totalMax = objectiveMax + nameQuestionMax + manualMax;
const admissionNumberLength = 9;
const answerCardQuestions = [
...examScoring.choiceQuestions.map((question) => ({ ...question, kind: "choice" })),
...examScoring.fillQuestions.map((question) => ({ ...question, kind: "fill" })),
...examScoring.manualQuestions.map((question) => ({ ...question, kind: "manual" }))
].sort((left, right) => left.number - right.number);
let questionNumber = 0;
---
<BaseLayout title={`${examIntro.title} · ${site.className}`}>
<main class="page-main exam-page">
<section class="page-hero">
<div class="section-inner">
<a class="back-link" href="/">返回首页</a>
<p class="eyebrow">{examIntro.eyebrow}</p>
<h1>{examIntro.title}</h1>
<p>{examIntro.description}</p>
<div class="hero-actions">
<a class="button primary" href={examIntro.downloadHref} download>下载原卷</a>
<a class="button" href="#paper">阅读试卷</a>
</div>
</div>
</section>
<section class="exam-band" id="paper">
<div class="section-inner">
<div class="exam-workspace">
<article class="exam-paper" aria-label="试卷正文">
{
lines.map((line, index) => {
const image = line.match(imagePattern);
const gallery = line.match(galleryPattern);
const question = line.match(questionPattern);
const options = getOptions(line);
if (image) {
return (
<figure class="exam-figure">
<img src={image[1]} alt={image[2]} loading="lazy" />
<figcaption>{image[2]}</figcaption>
</figure>
);
}
if (gallery) {
const images = gallery[1].split(",");
return (
<div class="exam-image-grid" aria-label="图片标题题配图">
{images.map((src, imageIndex) => (
<figure>
<img src={src} alt={`图片标题题配图 ${imageIndex + 1}`} loading="lazy" />
<figcaption>图 {imageIndex + 1}</figcaption>
{
questionNumber === 15 && (
<label class="image-title-answer">
<span>标题</span>
<input
data-fill-answer
data-question-number="15"
data-blank-index={imageIndex}
aria-label={`第 15 题图 ${imageIndex + 1} 标题`}
/>
</label>
)
}
</figure>
))}
</div>
);
}
if (question) {
questionNumber += 1;
const currentQuestionNumber = questionNumber;
const fillQuestion = fillQuestionMap.get(currentQuestionNumber);
return (
<div
class="exam-question-block"
id={`question-${currentQuestionNumber}`}
data-question-block
data-question-number={currentQuestionNumber}
>
<p class="exam-question">
<span class="exam-question-number">{currentQuestionNumber}</span>
<span class="exam-question-text" set:html={renderLine(question[1])} />
</p>
{
fillQuestion?.mode === "unordered-names" ? (
<div class="name-fill-grid" aria-label="第 14 题姓名默写作答">
{Array.from({ length: fillQuestion.blankCount }, (_, blankIndex) => (
<label>
<span>{blankIndex + 1}</span>
<input
data-fill-answer
data-question-number={currentQuestionNumber}
data-blank-index={blankIndex}
aria-label={`第 ${currentQuestionNumber} 题第 ${blankIndex + 1} 个姓名`}
/>
</label>
))}
</div>
) : null
}
</div>
);
}
if (options.length > 0) {
return (
<div class="exam-options" aria-label="选项">
{options.map((option) => (
<button
class="exam-option"
type="button"
data-select-option
data-question-number={questionNumber}
data-option-value={getOptionValue(option)}
set:html={renderLine(option)}
/>
))}
</div>
);
}
if (index === 0) {
return (
<div class="exam-candidate-info" aria-label="考生信息">
<label>
<span>姓名</span>
<input id="candidate-name" autocomplete="name" />
</label>
<label>
<span>准考证号</span>
<input
id="admission-number"
inputmode="numeric"
maxlength={admissionNumberLength}
pattern={`\\d{${admissionNumberLength}}`}
placeholder={"#".repeat(admissionNumberLength)}
/>
</label>
<label>
<span>座位号</span>
<input id="seat-number" inputmode="numeric" maxlength="2" readonly placeholder="末两位" />
</label>
</div>
);
}
if (index < 3) {
return (
<p
class:list={["exam-title-line", index === 1 && "major"]}
set:html={renderLine(line)}
/>
);
}
if (isSectionLine(line)) {
return <h2>{line}</h2>;
}
if (line === "注意事项:" || line === "注释:") {
return <h3>{line}</h3>;
}
if (questionNumber === 13 && line.includes("▲")) {
return <p class="fill-inline-text" set:html={renderQuestion13Line(line)} />;
}
return <p set:html={renderLine(line)} />;
})
}
</article>
<aside class="score-sidebar" id="scorer" aria-label="答题卡">
<form class="score-panel" id="exam-scorer">
<div class="score-sidebar-head">
<p class="eyebrow">Answer Sheet</p>
<h2>答题卡</h2>
<p>题号颜色会提示是否作答,点击题号可跳到对应题目。</p>
</div>
<section class="score-group answer-card-profile" aria-label="考生信息">
<dl>
<div>
<dt>姓名</dt>
<dd id="card-candidate-name">未填写</dd>
</div>
<div>
<dt>准考证号</dt>
<dd id="card-admission-number">未填写</dd>
</div>
<div>
<dt>座位号</dt>
<dd id="card-seat-number">未填写</dd>
</div>
</dl>
</section>
<section class="score-group" aria-labelledby="answer-nav-title">
<h3 id="answer-nav-title">题号</h3>
<div class="answer-card-grid">
{
answerCardQuestions.map((question) => (
<button
class="answer-card-number"
type="button"
data-answer-jump
data-kind={question.kind}
data-question-number={question.number}
aria-label={`跳到第 ${question.number} 题`}
>
{question.number}
</button>
))
}
</div>
<div class="answer-card-legend" aria-label="答题状态说明">
<span><i class="is-unanswered"></i>未答</span>
<span><i class="is-answered"></i>已答</span>
<span><i class="is-manual"></i>手评</span>
</div>
</section>
<section class="score-group" aria-labelledby="manual-score-title">
<h3 id="manual-score-title">主观题</h3>
<div class="manual-score-grid">
{
examScoring.manualQuestions.map((question) => (
<label class="manual-score-item">
<span>{question.number} 题</span>
<input
data-manual-score
data-points={question.points}
type="number"
min="0"
max={question.points}
step="0.5"
value="0"
/>
<small>/ {question.points}</small>
</label>
))
}
</div>
</section>
<button class="score-submit" type="submit">提交</button>
<div class="score-total" aria-live="polite">
<div>
<span>客观题</span>
<strong><output id="objective-score">0</output><small> / {objectiveMax}</small></strong>
</div>
<div>
<span>姓名题</span>
<strong><output id="name-score">0</output><small> / {nameQuestionMax}</small></strong>
</div>
<div>
<span>主观题</span>
<strong><output id="manual-score">0</output><small> / {manualMax}</small></strong>
</div>
<div class="score-total-final">
<span>总分</span>
<strong><output id="total-score">0</output><small> / {totalMax}</small></strong>
</div>
</div>
<p class="score-total-note">14 题为姓名题,单独计算得分;罚分只从姓名题和总分中体现,不计入客观题。</p>
</form>
</aside>
</div>
</div>
</section>
</main>
<script is:inline>
window.MathJax = {
startup: {
typeset: true
},
chtml: {
matchFontHeight: false
}
};
</script>
<script
is:inline
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/mml-chtml.js"
defer
></script>
<script
is:inline
define:vars={{
choiceBonus: examScoring.choiceBonus,
choiceQuestions: examScoring.choiceQuestions,
fillQuestions: examScoring.fillQuestions,
objectiveMax,
nameQuestionMax,
manualMax,
totalMax
}}
>
const scorer = document.querySelector("#exam-scorer");
const candidateName = document.querySelector("#candidate-name");
const admissionNumber = document.querySelector("#admission-number");
const seatNumber = document.querySelector("#seat-number");
const cardCandidateName = document.querySelector("#card-candidate-name");
const cardAdmissionNumber = document.querySelector("#card-admission-number");
const cardSeatNumber = document.querySelector("#card-seat-number");
const draftStorageKey = "examDraft";
const normalizeAnswer = (value) =>
String(value || "")
.trim()
.replace(/\s+/g, "")
.toLowerCase();
const getAcceptedAnswers = (answer) =>
(Array.isArray(answer) ? answer : String(answer || "").split("|"))
.map(normalizeAnswer)
.filter(Boolean);
const clampScore = (value, max) => {
const score = Number(value);
if (!Number.isFinite(score)) {
return 0;
}
return Math.min(Math.max(score, 0), max);
};
const getDraft = () => ({
candidate: {
name: candidateName?.value ?? "",
admissionNumber: admissionNumber?.value ?? "",
seatNumber: seatNumber?.value ?? ""
},
choices: Object.fromEntries(
[...document.querySelectorAll("[data-select-option].is-selected")].map((option) => [
option.dataset.questionNumber,
option.dataset.optionValue
])
),
fills: Object.fromEntries(
[...document.querySelectorAll("[data-fill-answer]")].map((input) => [
`${input.dataset.questionNumber}-${input.dataset.blankIndex || 0}`,
input.value
])
),
manualScores: Object.fromEntries(
[...document.querySelectorAll("[data-manual-score]")].map((input) => {
const label = input.closest(".manual-score-item");
const numberText = label?.querySelector("span")?.textContent ?? "";
const questionNumber = Number(numberText.match(/\d+/)?.[0] ?? 0);
return [questionNumber, input.value];
})
)
});
const saveDraft = () => {
localStorage.setItem(draftStorageKey, JSON.stringify(getDraft()));
};
const restoreDraft = () => {
const draft = JSON.parse(localStorage.getItem(draftStorageKey) || "null");
if (!draft) {
return;
}
if (candidateName) {
candidateName.value = draft.candidate?.name || "";
}
if (admissionNumber) {
admissionNumber.value = draft.candidate?.admissionNumber || "";
}
if (seatNumber) {
seatNumber.value = draft.candidate?.seatNumber || admissionNumber?.value.slice(-2) || "";
}
Object.entries(draft.choices || {}).forEach(([questionNumber, optionValue]) => {
const option = document.querySelector(
`[data-select-option][data-question-number="${questionNumber}"][data-option-value="${optionValue}"]`
);
option?.classList.add("is-selected");
});
Object.entries(draft.fills || {}).forEach(([key, value]) => {
const [questionNumber, blankIndex] = key.split("-");
const input = document.querySelector(
`[data-fill-answer][data-question-number="${questionNumber}"][data-blank-index="${blankIndex}"]`
);
if (input) {
input.value = value;
}
});
document.querySelectorAll("[data-manual-score]").forEach((input) => {
const label = input.closest(".manual-score-item");
const numberText = label?.querySelector("span")?.textContent ?? "";
const questionNumber = Number(numberText.match(/\d+/)?.[0] ?? 0);
if (draft.manualScores?.[questionNumber] != null) {
input.value = draft.manualScores[questionNumber];
}
});
};
const syncSelectedOption = (questionNumber, userAnswer) => {
document
.querySelectorAll(`[data-select-option][data-question-number="${questionNumber}"]`)
.forEach((option) =>
option.classList.toggle(
"is-selected",
Boolean(userAnswer) && option.dataset.optionValue === userAnswer
)
);
};
const syncCandidateInfo = () => {
if (cardCandidateName) {
cardCandidateName.textContent = candidateName?.value.trim() || "未填写";
}
if (cardAdmissionNumber) {
cardAdmissionNumber.textContent = admissionNumber?.value.trim() || "未填写";
}
if (cardSeatNumber) {
cardSeatNumber.textContent = seatNumber?.value.trim() || "未填写";
}
};
const setAnswerCardState = (questionNumber, isAnswered) => {
const cardNumber = document.querySelector(
`[data-answer-jump][data-question-number="${questionNumber}"]`
);
cardNumber?.classList.toggle("is-answered", Boolean(isAnswered));
};
const updateAnswerCardStates = () => {
choiceQuestions.forEach((question) => {
const selectedOption = document.querySelector(
`[data-select-option][data-question-number="${question.number}"].is-selected`
);
setAnswerCardState(question.number, Boolean(selectedOption));
});
fillQuestions.forEach((question) => {
const inputs = [...document.querySelectorAll(`[data-fill-answer][data-question-number="${question.number}"]`)];
setAnswerCardState(question.number, inputs.some((input) => input.value.trim()));
});
scorer?.querySelectorAll("[data-manual-score]").forEach((input) => {
const label = input.closest(".manual-score-item");
const numberText = label?.querySelector("span")?.textContent ?? "";
const questionNumber = Number(numberText.match(/\d+/)?.[0] ?? 0);
if (questionNumber) {
setAnswerCardState(questionNumber, Number(input.value) > 0);
}
});
};
const updateScores = () => {
if (!scorer) {
return null;
}
let objectiveScore = 0;
let manualScore = 0;
let choiceScore = 0;
let fillScore = 0;
let choiceAnswered = 0;
let fillAnswered = 0;
let manualAnswered = 0;
let namePenalty = 0;
let nameScore = 0;
let rawTotal = 0;
const correctChoiceNumbers = new Set();
choiceQuestions.forEach((question) => {
const selectedOption = document.querySelector(
`[data-select-option][data-question-number="${question.number}"].is-selected`
);
const userAnswer = selectedOption?.dataset.optionValue ?? "";
if (userAnswer) {
choiceAnswered += 1;
}
if (question.freebie && userAnswer) {
objectiveScore += question.points;
choiceScore += question.points;
correctChoiceNumbers.add(question.number);
return;
}
if (question.answer && userAnswer === question.answer) {
objectiveScore += question.points;
choiceScore += question.points;
correctChoiceNumbers.add(question.number);
}
});
fillQuestions.forEach((question) => {
const inputs = [...document.querySelectorAll(`[data-fill-answer][data-question-number="${question.number}"]`)]
.sort((left, right) => Number(left.dataset.blankIndex || 0) - Number(right.dataset.blankIndex || 0));
const answers = Array.isArray(question.answers) ? question.answers : [];
if (question.mode === "unordered-names") {
const standardNames = answers.map(normalizeAnswer).filter(Boolean);
const submittedNames = inputs.map((input) => normalizeAnswer(input.value)).filter(Boolean);
if (submittedNames.length > 0) {
fillAnswered += 1;
}
if (standardNames.length > 0) {
const standardSet = new Set(standardNames);
const submittedSet = new Set(submittedNames);
const correctCount = [...submittedSet].filter((name) => standardSet.has(name)).length;
const missingCount = Math.max(0, standardSet.size - correctCount);
const wrongCount = submittedNames.filter((name) => !standardSet.has(name)).length;
namePenalty = (missingCount + wrongCount) * (question.penaltyPerMistake ?? 1);
nameScore = question.points - namePenalty;
}
return;
}
let questionHasAnswer = false;
answers.forEach((answer, index) => {
const acceptedAnswers = getAcceptedAnswers(answer);
const normalizedUserAnswer = normalizeAnswer(inputs[index]?.value ?? "");
const blankPoints = question.blankPoints ?? question.points / Math.max(answers.length, 1);
if (normalizedUserAnswer) {
questionHasAnswer = true;
}
if (question.freebie && normalizedUserAnswer) {
objectiveScore += blankPoints;
fillScore += blankPoints;
return;
}
if (acceptedAnswers.length > 0 && acceptedAnswers.includes(normalizedUserAnswer)) {
objectiveScore += blankPoints;
fillScore += blankPoints;
}
});
if (questionHasAnswer) {
fillAnswered += 1;
}
});
const bonusApplies =
choiceBonus.appliesTo.length > 0 &&
choiceBonus.appliesTo.every((number) => correctChoiceNumbers.has(number));
if (bonusApplies) {
objectiveScore += choiceBonus.points;
choiceScore += choiceBonus.points;
}
scorer.querySelectorAll("[data-manual-score]").forEach((input) => {
const points = Number(input.dataset.points || 0);
const score = clampScore(input.value, points);
if (String(input.value) !== String(score)) {
input.value = String(score);
}
manualScore += score;
if (score > 0) {
manualAnswered += 1;
}
});
const objectiveOutput = document.querySelector("#objective-score");
const nameOutput = document.querySelector("#name-score");
const manualOutput = document.querySelector("#manual-score");
const totalOutput = document.querySelector("#total-score");
const subtotal = objectiveScore + nameScore + manualScore;
rawTotal = subtotal;
const displayTotal = Math.max(0, rawTotal);
if (objectiveOutput) {
objectiveOutput.value = objectiveScore;
objectiveOutput.textContent = objectiveScore;
}
if (nameOutput) {
nameOutput.value = nameScore;
nameOutput.textContent = nameScore;
}
if (manualOutput) {
manualOutput.value = manualScore;
manualOutput.textContent = manualScore;
}
if (totalOutput) {
totalOutput.value = displayTotal;
totalOutput.textContent = displayTotal;
}
return {
candidate: {
name: candidateName?.value.trim() || "未填写",
admissionNumber: admissionNumber?.value.trim() || "未填写",
seatNumber: seatNumber?.value.trim() || "未填写"
},
score: {
objective: objectiveScore,
name: nameScore,
manual: manualScore,
subtotal,
total: displayTotal,
rawTotal,
objectiveMax,
nameQuestionMax,
manualMax,
totalMax
},
breakdown: {
choiceScore,
fillScore,
choiceAnswered,
choiceTotal: choiceQuestions.length,
fillAnswered,
fillTotal: fillQuestions.length,
manualAnswered,
manualTotal: scorer.querySelectorAll("[data-manual-score]").length,
bonusApplied: bonusApplies,
namePenalty,
totalFloored: rawTotal < 0
},
submittedAt: new Date().toISOString()
};
};
scorer?.addEventListener("submit", (event) => {
event.preventDefault();
const result = updateScores();
if (result) {
sessionStorage.setItem("examResult", JSON.stringify(result));
window.location.href = "/exam/result/";
}
});
document.querySelectorAll("[data-select-option]").forEach((button) => {
button.addEventListener("click", () => {
const questionNumber = button.dataset.questionNumber;
const optionValue = button.dataset.optionValue;
if (!questionNumber || !optionValue) {
return;
}
document
.querySelectorAll(`[data-select-option][data-question-number="${questionNumber}"]`)
.forEach((option) => option.classList.toggle("is-selected", option === button));
updateAnswerCardStates();
saveDraft();
});
});
document.querySelectorAll("[data-fill-answer]").forEach((input) => {
input.addEventListener("input", () => {
updateAnswerCardStates();
saveDraft();
});
});
scorer?.querySelectorAll("[data-manual-score]").forEach((input) => {
input.addEventListener("input", () => {
updateAnswerCardStates();
saveDraft();
});
});
document.querySelectorAll("[data-answer-jump]").forEach((button) => {
button.addEventListener("click", () => {
const question = document.querySelector(`#question-${button.dataset.questionNumber}`);
question?.scrollIntoView({
behavior: "smooth",
block: "start"
});
});
});
admissionNumber?.addEventListener("input", () => {
admissionNumber.value = admissionNumber.value.replace(/\D/g, "").slice(0, admissionNumber.maxLength);
if (seatNumber) {
seatNumber.value = admissionNumber.value.slice(-2);
}
syncCandidateInfo();
saveDraft();
});
candidateName?.addEventListener("input", () => {
syncCandidateInfo();
saveDraft();
});
seatNumber?.addEventListener("input", () => {
syncCandidateInfo();
saveDraft();
});
restoreDraft();
syncCandidateInfo();
updateAnswerCardStates();
</script>
</BaseLayout>
+226
View File
@@ -0,0 +1,226 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { site } from "../../data/site";
---
<BaseLayout title={`成绩分析 · ${site.className}`}>
<main class="page-main">
<section class="page-hero result-hero">
<div class="section-inner">
<a class="back-link" href="/exam/">返回试卷</a>
<p class="eyebrow">Result</p>
<h1>成绩分析</h1>
<p>这里展示本次答题的得分、完成情况和可下载的分享图。</p>
</div>
</section>
<section class="result-band">
<div class="section-inner">
<article class="result-card" id="result-card">
<div class="result-empty" id="result-empty">
<h2>还没有成绩</h2>
<p>请先完成试卷并点击答题卡里的“提交”。</p>
<a class="section-link" href="/exam/">去答题</a>
</div>
<div class="result-content" id="result-content" hidden>
<div class="result-head">
<div>
<p class="eyebrow">综合素质检测模拟卷</p>
<h2 id="result-title">成绩单</h2>
</div>
<button class="score-submit" type="button" id="download-share">下载分享图</button>
</div>
<dl class="result-profile">
<div>
<dt>姓名</dt>
<dd id="result-name">未填写</dd>
</div>
<div>
<dt>准考证号</dt>
<dd id="result-admission">未填写</dd>
</div>
<div>
<dt>座位号</dt>
<dd id="result-seat">未填写</dd>
</div>
</dl>
<div class="result-score-grid">
<div class="result-total">
<span>总分</span>
<strong><output id="result-total">0</output></strong>
<small id="result-total-max">/ 260</small>
</div>
<div>
<span>客观题</span>
<strong><output id="result-objective">0</output></strong>
<small id="result-objective-max">/ 135</small>
</div>
<div>
<span>主观题</span>
<strong><output id="result-manual">0</output></strong>
<small id="result-manual-max">/ 125</small>
</div>
<div class="result-name-score">
<span>姓名题</span>
<strong><output id="result-name-score">0</output></strong>
<small id="result-name-score-max">/ 30</small>
</div>
</div>
<p class="result-score-note">14 题姓名题单独计算,分数可低于 0;客观题不包含该题分数。</p>
<div class="result-analysis">
<h2>完成情况</h2>
<div class="analysis-grid">
<p><strong id="choice-progress">0 / 0</strong><span>选择题作答</span></p>
<p><strong id="fill-progress">0 / 0</strong><span>填空题作答</span></p>
<p><strong id="manual-progress">0 / 0</strong><span>主观题给分</span></p>
<p><strong id="bonus-status">未获得</strong><span>选择题奖励</span></p>
</div>
<div class="penalty-note" id="penalty-note" hidden>
<strong>14 题罚分:<span id="name-penalty">0</span> 分</strong>
<span id="floor-note" hidden>原始总分为负,最终总分已按 0 分记录。</span>
</div>
<p class="result-comment" id="result-comment"></p>
</div>
</div>
</article>
</div>
</section>
</main>
<script is:inline>
const result = JSON.parse(sessionStorage.getItem("examResult") || "null");
const empty = document.querySelector("#result-empty");
const content = document.querySelector("#result-content");
const setText = (selector, value) => {
const element = document.querySelector(selector);
if (element) {
element.textContent = String(value);
}
};
const getComment = (score, max) => {
const ratio = max > 0 ? score / max : 0;
if (ratio >= 0.9) return "状态拉满,很有当年考场上那股稳劲。";
if (ratio >= 0.75) return "整体发挥不错,很多题都稳稳拿住了。";
if (ratio >= 0.6) return "完成度还可以,主观题和填空题还能继续捞分。";
return "这张卷子本来就带点纪念性质,答完就已经很有参与感。";
};
const drawShareImage = (data) => {
const canvas = document.createElement("canvas");
const scale = window.devicePixelRatio || 1;
const width = 960;
const height = 1280;
canvas.width = width * scale;
canvas.height = height * scale;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
ctx.scale(scale, scale);
ctx.fillStyle = "#fffdf7";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#24443a";
ctx.fillRect(0, 0, width, 240);
ctx.fillStyle = "#f3cf8b";
ctx.font = "700 28px Microsoft YaHei, sans-serif";
ctx.fillText("2024届612班", 72, 86);
ctx.fillStyle = "#fffdf7";
ctx.font = "800 54px Microsoft YaHei, sans-serif";
ctx.fillText("综合素质检测模拟卷", 72, 160);
ctx.font = "700 28px Microsoft YaHei, sans-serif";
ctx.fillText("成绩分析", 72, 208);
ctx.fillStyle = "#1f2b2a";
ctx.font = "700 30px Microsoft YaHei, sans-serif";
ctx.fillText(`姓名:${data.candidate.name}`, 72, 310);
ctx.fillText(`准考证号:${data.candidate.admissionNumber}`, 72, 360);
ctx.fillText(`座位号:${data.candidate.seatNumber}`, 72, 410);
ctx.fillStyle = "#376d5a";
ctx.font = "900 140px Microsoft YaHei, sans-serif";
ctx.fillText(String(data.score.total), 72, 610);
ctx.font = "800 38px Microsoft YaHei, sans-serif";
ctx.fillText(`/ ${data.score.totalMax}`, 340, 590);
ctx.fillStyle = "#62706f";
ctx.font = "700 28px Microsoft YaHei, sans-serif";
ctx.fillText(`客观题 ${data.score.objective} / ${data.score.objectiveMax}`, 72, 690);
ctx.fillText(`主观题 ${data.score.manual} / ${data.score.manualMax}`, 72, 740);
ctx.fillText(`姓名题 ${data.score.name ?? 0} / ${data.score.nameQuestionMax ?? 0}`, 72, 790);
ctx.fillStyle = "#f4f7ee";
ctx.fillRect(72, 820, 816, 230);
ctx.fillStyle = "#1f2b2a";
ctx.font = "800 30px Microsoft YaHei, sans-serif";
ctx.fillText("完成情况", 110, 880);
ctx.font = "700 24px Microsoft YaHei, sans-serif";
ctx.fillText(`选择题:${data.breakdown.choiceAnswered} / ${data.breakdown.choiceTotal}`, 110, 935);
ctx.fillText(`填空题:${data.breakdown.fillAnswered} / ${data.breakdown.fillTotal}`, 110, 985);
ctx.fillText(`主观题:${data.breakdown.manualAnswered} / ${data.breakdown.manualTotal}`, 500, 935);
ctx.fillText(`奖励分:${data.breakdown.bonusApplied ? "已获得" : "未获得"}`, 500, 985);
if (data.breakdown.namePenalty > 0 || data.breakdown.totalFloored) {
ctx.fillStyle = "#c96452";
ctx.font = "800 24px Microsoft YaHei, sans-serif";
ctx.fillText(`14题罚分:${data.breakdown.namePenalty || 0}分`, 110, 1035);
if (data.breakdown.totalFloored) {
ctx.fillText("原始总分为负,最终总分按0分记录", 500, 1035);
}
}
ctx.fillStyle = "#62706f";
ctx.font = "700 24px Microsoft YaHei, sans-serif";
ctx.fillText("青春是一本太仓促的书,但这张成绩单刚刚好。", 72, 1140);
const link = document.createElement("a");
link.download = `612-score-${Date.now()}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
};
if (result) {
empty.hidden = true;
content.hidden = false;
setText("#result-title", `${result.candidate.name}的成绩单`);
setText("#result-name", result.candidate.name);
setText("#result-admission", result.candidate.admissionNumber);
setText("#result-seat", result.candidate.seatNumber);
setText("#result-total", result.score.total);
setText("#result-total-max", `/ ${result.score.totalMax}`);
setText("#result-objective", result.score.objective);
setText("#result-objective-max", `/ ${result.score.objectiveMax}`);
setText("#result-manual", result.score.manual);
setText("#result-manual-max", `/ ${result.score.manualMax}`);
setText("#result-name-score", result.score.name ?? 0);
setText("#result-name-score-max", `/ ${result.score.nameQuestionMax ?? 0}`);
setText("#choice-progress", `${result.breakdown.choiceAnswered} / ${result.breakdown.choiceTotal}`);
setText("#fill-progress", `${result.breakdown.fillAnswered} / ${result.breakdown.fillTotal}`);
setText("#manual-progress", `${result.breakdown.manualAnswered} / ${result.breakdown.manualTotal}`);
setText("#bonus-status", result.breakdown.bonusApplied ? "已获得" : "未获得");
setText("#result-comment", getComment(result.score.total, result.score.totalMax));
if (result.breakdown.namePenalty > 0 || result.breakdown.totalFloored) {
document.querySelector("#penalty-note").hidden = false;
setText("#name-penalty", result.breakdown.namePenalty || 0);
if (result.breakdown.totalFloored) {
document.querySelector("#floor-note").hidden = false;
}
}
}
document.querySelector("#download-share")?.addEventListener("click", () => {
if (result) {
drawShareImage(result);
}
});
</script>
</BaseLayout>