835 lines
29 KiB
Plaintext
835 lines
29 KiB
Plaintext
---
|
||
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.previewHref} target="_blank" rel="noreferrer">预览原卷</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>
|
||
|
||
<button
|
||
class="score-fab"
|
||
type="button"
|
||
aria-controls="scorer"
|
||
aria-expanded="false"
|
||
aria-label="打开答题卡"
|
||
data-score-toggle
|
||
>
|
||
答
|
||
</button>
|
||
<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 scoreSidebar = document.querySelector("#scorer");
|
||
const scoreToggle = document.querySelector("[data-score-toggle]");
|
||
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 setScoreMenuOpen = (isOpen) => {
|
||
scoreSidebar?.classList.toggle("is-open", isOpen);
|
||
|
||
if (scoreToggle) {
|
||
scoreToggle.setAttribute("aria-expanded", String(isOpen));
|
||
scoreToggle.setAttribute("aria-label", isOpen ? "关闭答题卡" : "打开答题卡");
|
||
scoreToggle.textContent = isOpen ? "×" : "答";
|
||
}
|
||
};
|
||
|
||
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 clampManualScoreInput = (input) => {
|
||
const value = input.value.trim();
|
||
|
||
if (!value) {
|
||
return;
|
||
}
|
||
|
||
const max = Number(input.dataset.points || input.max || 0);
|
||
const score = Number(value);
|
||
|
||
if (!Number.isFinite(score)) {
|
||
return;
|
||
}
|
||
|
||
const clampedScore = clampScore(score, max);
|
||
|
||
if (score !== clampedScore) {
|
||
input.value = String(clampedScore);
|
||
}
|
||
};
|
||
|
||
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];
|
||
clampManualScoreInput(input);
|
||
}
|
||
});
|
||
};
|
||
|
||
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", () => {
|
||
clampManualScoreInput(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"
|
||
});
|
||
setScoreMenuOpen(false);
|
||
});
|
||
});
|
||
scoreToggle?.addEventListener("click", () => {
|
||
setScoreMenuOpen(!scoreSidebar?.classList.contains("is-open"));
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
setScoreMenuOpen(false);
|
||
}
|
||
});
|
||
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>
|