import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import {
|
Button,
|
Card,
|
Descriptions,
|
Form,
|
Grid,
|
Input,
|
Radio,
|
Select,
|
Spin,
|
Tag,
|
Typography,
|
} from "antd";
|
import { DoubleLeftOutlined, DoubleRightOutlined } from "@ant-design/icons";
|
import { api } from "../api";
|
import {
|
attemptDetailPath,
|
readScoreListQuery,
|
type ScoreListQuery,
|
} from "../scoreQueryParams";
|
import "./AttemptReviewPage.css";
|
|
const { useBreakpoint } = Grid;
|
const { Title } = Typography;
|
|
const SECTION_CN = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
|
|
type Q = {
|
external_item_id: string;
|
part_name?: string;
|
item_order?: number;
|
title_html?: string;
|
detail_json?: string;
|
reference_answer?: string;
|
user_answer?: string;
|
is_correct: boolean;
|
score: number;
|
max_score?: number;
|
};
|
|
type Part = { part_name: string; questions: Q[] };
|
|
type Attempt = {
|
attempt_id: string;
|
examinee_name?: string;
|
id_card?: string;
|
score: number;
|
paper_total_score?: number;
|
paper_name?: string;
|
subject_name?: string;
|
room_id?: string;
|
seat_number?: string;
|
start_time?: string;
|
end_time?: string;
|
parts: Part[];
|
};
|
|
type FlatItem = { key: string; part: string; q: Q; idx: number; displayNo: number };
|
|
function stripHtml(html: string) {
|
return html.replace(/<[^>]+>/g, "");
|
}
|
|
function isAnswered(q: Q) {
|
return Boolean(q.user_answer?.trim());
|
}
|
|
function bubbleClassName(q: Q, isActive: boolean) {
|
const classes = ["attempt-review__bubble"];
|
if (!isAnswered(q)) classes.push("attempt-review__bubble--unanswered");
|
else if (q.is_correct) classes.push("attempt-review__bubble--correct");
|
else classes.push("attempt-review__bubble--wrong");
|
if (isActive) classes.push("attempt-review__bubble--active");
|
return classes.join(" ");
|
}
|
|
function parseOptions(detail_json?: string) {
|
if (!detail_json) return [];
|
try {
|
const o = JSON.parse(detail_json);
|
const qs = o?.item?.questions;
|
if (Array.isArray(qs) && qs[0]?.options) return qs[0].options;
|
} catch {
|
/* ignore */
|
}
|
return [];
|
}
|
|
function scrollChildIntoContainer(container: HTMLElement, el: HTMLElement) {
|
const cRect = container.getBoundingClientRect();
|
const eRect = el.getBoundingClientRect();
|
const nextTop = container.scrollTop + (eRect.top - cRect.top);
|
container.scrollTo({ top: Math.max(0, nextTop), behavior: "auto" });
|
}
|
|
function QuestionIndex({
|
parts,
|
flat,
|
activeKey,
|
collapsed,
|
onToggleCollapse,
|
onJump,
|
mobile,
|
}: {
|
parts: Part[];
|
flat: FlatItem[];
|
activeKey: string | null;
|
collapsed: boolean;
|
onToggleCollapse: () => void;
|
onJump: (key: string) => void;
|
mobile?: boolean;
|
}) {
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
const visibleParts = useMemo(() => {
|
if (typeFilter === "all") return parts;
|
return parts.filter((p) => p.part_name === typeFilter);
|
}, [parts, typeFilter]);
|
|
return (
|
<aside
|
className={[
|
"attempt-review__index",
|
mobile ? "attempt-review__index-mobile" : "",
|
collapsed && !mobile ? "attempt-review__index--collapsed" : "",
|
]
|
.filter(Boolean)
|
.join(" ")}
|
aria-label="题目索引"
|
>
|
<div className="attempt-review__index-toolbar">
|
{!mobile && (
|
<Button
|
type="text"
|
size="small"
|
icon={collapsed ? <DoubleRightOutlined /> : <DoubleLeftOutlined />}
|
onClick={onToggleCollapse}
|
aria-label={collapsed ? "展开索引侧栏" : "收起索引侧栏"}
|
/>
|
)}
|
</div>
|
{collapsed && !mobile ? (
|
<div className="attempt-review__index-collapsed-label">题目索引</div>
|
) : (
|
<>
|
<div className="attempt-review__index-filter">
|
<Radio.Group
|
size="small"
|
value={typeFilter}
|
onChange={(e) => setTypeFilter(e.target.value)}
|
className="attempt-review__type-radio"
|
>
|
<Radio.Button value="all">全部</Radio.Button>
|
{parts.map((p) => (
|
<Radio.Button key={p.part_name} value={p.part_name}>{p.part_name}</Radio.Button>
|
))}
|
</Radio.Group>
|
</div>
|
<div className="attempt-review__index-body">
|
{visibleParts.map((p) => {
|
const sectionIdx = parts.findIndex((x) => x.part_name === p.part_name);
|
const sectionLabel = `${SECTION_CN[sectionIdx] ?? sectionIdx + 1}、${p.part_name}`;
|
return (
|
<section key={p.part_name} className="attempt-review__section">
|
<h4 className="attempt-review__section-title">{sectionLabel}</h4>
|
<div className="attempt-review__bubble-grid">
|
{p.questions.map((q, i) => {
|
const meta = flat.find((f) => f.key === q.external_item_id);
|
const no = meta?.displayNo ?? (q.item_order ?? i) + 1;
|
const isActive = activeKey === q.external_item_id;
|
return (
|
<button
|
key={q.external_item_id}
|
type="button"
|
className={bubbleClassName(q, isActive)}
|
onClick={() => onJump(q.external_item_id)}
|
title={`第 ${no} 题`}
|
>
|
{no}
|
</button>
|
);
|
})}
|
</div>
|
</section>
|
);
|
})}
|
</div>
|
</>
|
)}
|
</aside>
|
);
|
}
|
|
type ScoreListItem = {
|
attempt_id: string;
|
name: string;
|
id_card: string;
|
subject_name: string;
|
score: number;
|
};
|
|
const ROSTER_PAGE_SIZE = 40;
|
|
function AttemptRoster({
|
activeAttemptId,
|
listQuery,
|
onSelectAttempt,
|
onSearch,
|
}: {
|
activeAttemptId: string;
|
listQuery: ScoreListQuery;
|
onSelectAttempt: (attemptId: string) => void;
|
onSearch: (filters: Pick<ScoreListQuery, "name" | "id_card" | "subject">) => void;
|
}) {
|
const [form] = Form.useForm<Pick<ScoreListQuery, "name" | "id_card" | "subject">>();
|
const [subjects, setSubjects] = useState<string[]>([]);
|
const [items, setItems] = useState<ScoreListItem[]>([]);
|
const [total, setTotal] = useState(0);
|
const [page, setPage] = useState(1);
|
const [loading, setLoading] = useState(false);
|
const [filters, setFilters] = useState({
|
name: listQuery.name,
|
id_card: listQuery.id_card,
|
subject: listQuery.subject,
|
});
|
const scrollRef = useRef<HTMLDivElement>(null);
|
const loadingMoreRef = useRef(false);
|
|
useEffect(() => {
|
form.setFieldsValue({
|
name: listQuery.name,
|
id_card: listQuery.id_card,
|
subject: listQuery.subject,
|
});
|
setFilters({
|
name: listQuery.name,
|
id_card: listQuery.id_card,
|
subject: listQuery.subject,
|
});
|
}, [listQuery.name, listQuery.id_card, listQuery.subject, form]);
|
|
useEffect(() => {
|
api.get<{ items: string[] }>("/api/subjects").then((r) => setSubjects(r.data.items));
|
}, []);
|
|
const fetchPage = useCallback(
|
async (p: number, append: boolean) => {
|
if (loadingMoreRef.current) return;
|
loadingMoreRef.current = true;
|
setLoading(true);
|
try {
|
const { data: res } = await api.get("/api/scores", {
|
params: { page: p, page_size: ROSTER_PAGE_SIZE, ...filters },
|
});
|
setTotal(res.total);
|
setPage(p);
|
setItems((prev) => (append ? [...prev, ...res.items] : res.items));
|
} finally {
|
setLoading(false);
|
loadingMoreRef.current = false;
|
}
|
},
|
[filters]
|
);
|
|
useEffect(() => {
|
fetchPage(1, false);
|
}, [fetchPage]);
|
|
useEffect(() => {
|
const el = scrollRef.current;
|
if (!el) return;
|
const onScroll = () => {
|
if (loadingMoreRef.current || loading) return;
|
if (items.length >= total) return;
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 64) {
|
fetchPage(page + 1, true);
|
}
|
};
|
el.addEventListener("scroll", onScroll);
|
return () => el.removeEventListener("scroll", onScroll);
|
}, [loading, items.length, total, page, fetchPage]);
|
|
useEffect(() => {
|
const el = scrollRef.current?.querySelector<HTMLElement>(
|
`[data-attempt-id="${activeAttemptId}"]`
|
);
|
el?.scrollIntoView({ block: "nearest" });
|
}, [activeAttemptId, items]);
|
|
return (
|
<aside className="attempt-review__roster">
|
<Form
|
form={form}
|
className="attempt-review__roster-form"
|
size="small"
|
layout="horizontal"
|
labelAlign="right"
|
colon={false}
|
labelCol={{ flex: "0 0 52px" }}
|
wrapperCol={{ flex: "1 1 0" }}
|
onFinish={(v) => onSearch(v)}
|
>
|
<Form.Item name="name" label="姓名" className="attempt-review__roster-field">
|
<Input allowClear />
|
</Form.Item>
|
<Form.Item name="id_card" label="证件号" className="attempt-review__roster-field">
|
<Input allowClear />
|
</Form.Item>
|
<Form.Item name="subject" label="科目" className="attempt-review__roster-field">
|
<Select allowClear options={subjects.map((s) => ({ value: s, label: s }))} />
|
</Form.Item>
|
<Button type="primary" htmlType="submit" block size="small">查询</Button>
|
</Form>
|
<div ref={scrollRef} className="attempt-review__roster-list">
|
{items.map((row) => (
|
<button
|
key={row.attempt_id}
|
type="button"
|
data-attempt-id={row.attempt_id}
|
className={[
|
"attempt-review__roster-item",
|
row.attempt_id === activeAttemptId ? "attempt-review__roster-item--active" : "",
|
]
|
.filter(Boolean)
|
.join(" ")}
|
onClick={() => {
|
if (row.attempt_id !== activeAttemptId) onSelectAttempt(row.attempt_id);
|
}}
|
>
|
<div className="attempt-review__roster-name">{row.name || "—"}</div>
|
<div className="attempt-review__roster-meta">
|
<span>{row.score} 分</span>
|
<span className="attempt-review__roster-subject">{row.subject_name}</span>
|
</div>
|
</button>
|
))}
|
{loading && <div className="attempt-review__roster-hint">加载中…</div>}
|
{!loading && items.length === 0 && <div className="attempt-review__roster-hint">无数据</div>}
|
{!loading && items.length > 0 && items.length >= total && (
|
<div className="attempt-review__roster-hint">已加载全部</div>
|
)}
|
</div>
|
</aside>
|
);
|
}
|
|
export default function AttemptReviewPage() {
|
const { attemptId } = useParams();
|
const navigate = useNavigate();
|
const [searchParams] = useSearchParams();
|
const listQuery = readScoreListQuery(searchParams);
|
const [data, setData] = useState<Attempt | null>(null);
|
const [detailLoading, setDetailLoading] = useState(true);
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
const [indexCollapsed, setIndexCollapsed] = useState(false);
|
const detailScrollRef = useRef<HTMLDivElement>(null);
|
const screens = useBreakpoint();
|
const isWide = !!screens.lg;
|
|
const selectAttempt = useCallback(
|
(id: string) => {
|
if (id === attemptId) return;
|
navigate(attemptDetailPath(id, listQuery), { replace: true, preventScrollReset: true });
|
},
|
[attemptId, listQuery, navigate]
|
);
|
|
const searchRoster = useCallback(
|
(filters: Pick<ScoreListQuery, "name" | "id_card" | "subject">) => {
|
navigate(
|
attemptDetailPath(attemptId!, { ...filters, page: 1, page_size: listQuery.page_size }),
|
{ replace: true, preventScrollReset: true }
|
);
|
},
|
[attemptId, listQuery.page_size, navigate]
|
);
|
|
useEffect(() => {
|
if (!attemptId) return;
|
let cancelled = false;
|
setDetailLoading(true);
|
setActiveKey(null);
|
detailScrollRef.current?.scrollTo(0, 0);
|
api
|
.get<Attempt>(`/api/attempts/${attemptId}`)
|
.then((r) => {
|
if (!cancelled) setData(r.data);
|
})
|
.finally(() => {
|
if (!cancelled) setDetailLoading(false);
|
});
|
return () => {
|
cancelled = true;
|
};
|
}, [attemptId]);
|
|
useEffect(() => {
|
const prev = document.documentElement.style.overflow;
|
const prevBody = document.body.style.overflow;
|
document.documentElement.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
return () => {
|
document.documentElement.style.overflow = prev;
|
document.body.style.overflow = prevBody;
|
};
|
}, []);
|
|
const flat = useMemo(() => {
|
if (!data) return [];
|
const out: FlatItem[] = [];
|
let n = 0;
|
data.parts.forEach((p) =>
|
p.questions.forEach((q, i) => {
|
n += 1;
|
out.push({
|
key: q.external_item_id,
|
part: p.part_name,
|
q,
|
idx: n,
|
displayNo: (q.item_order ?? i) + 1,
|
});
|
})
|
);
|
return out;
|
}, [data]);
|
|
const jumpTo = useCallback((key: string) => {
|
setActiveKey(key);
|
requestAnimationFrame(() => {
|
const container = detailScrollRef.current;
|
const el = document.getElementById(`q-${key}`);
|
if (!container || !el) return;
|
scrollChildIntoContainer(container, el);
|
});
|
}, []);
|
|
useEffect(() => {
|
const container = detailScrollRef.current;
|
if (!container || !data || flat.length === 0) return;
|
|
const observer = new IntersectionObserver(
|
(entries) => {
|
const visible = entries
|
.filter((e) => e.isIntersecting)
|
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
|
const id = visible[0]?.target.id;
|
if (id?.startsWith("q-")) {
|
setActiveKey(id.replace("q-", ""));
|
}
|
},
|
{ root: container, rootMargin: "-8px 0px -70% 0px", threshold: 0 }
|
);
|
|
flat.forEach(({ key }) => {
|
const el = document.getElementById(`q-${key}`);
|
if (el) observer.observe(el);
|
});
|
return () => observer.disconnect();
|
}, [data, flat]);
|
|
const indexPanel = data ? (
|
<QuestionIndex
|
parts={data.parts}
|
flat={flat}
|
activeKey={activeKey}
|
collapsed={indexCollapsed}
|
onToggleCollapse={() => setIndexCollapsed((c) => !c)}
|
onJump={jumpTo}
|
mobile={!isWide}
|
/>
|
) : null;
|
|
const detailPanel = data ? (
|
<div className="attempt-review__detail-inner">
|
{data.parts.map((p) => (
|
<div key={p.part_name} id={`part-${p.part_name}`}>
|
<Title level={5}>{p.part_name}</Title>
|
{p.questions.map((q, i) => (
|
<div key={q.external_item_id} id={`q-${q.external_item_id}`} className="attempt-review__question-anchor">
|
<Card
|
size="small"
|
className="attempt-review__question-card"
|
title={`第 ${(q.item_order ?? i) + 1} 题`}
|
>
|
<div dangerouslySetInnerHTML={{ __html: q.title_html || "" }} />
|
<ul>
|
{parseOptions(q.detail_json).map((opt: { identifier: string; text: string }) => (
|
<li key={opt.identifier}>{opt.identifier}. {stripHtml(opt.text)}</li>
|
))}
|
</ul>
|
<p>考生答案:{q.user_answer || "—"}</p>
|
<p>参考答案:{q.reference_answer || "—"}</p>
|
<p>
|
{q.is_correct ? <Tag color="success">正确</Tag> : <Tag color="error">错误</Tag>}
|
得分:{q.score} / {q.max_score ?? 1}
|
</p>
|
</Card>
|
</div>
|
))}
|
</div>
|
))}
|
</div>
|
) : null;
|
|
const centerRight = (
|
<div className="attempt-review__center-right">
|
{detailLoading && (
|
<div className="attempt-review__center-right-mask" aria-busy="true">
|
<Spin />
|
</div>
|
)}
|
{data && (
|
<Card className="attempt-review__header" size="small">
|
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
<Descriptions.Item label="姓名">{data.examinee_name}</Descriptions.Item>
|
<Descriptions.Item label="证件号">{data.id_card}</Descriptions.Item>
|
<Descriptions.Item label="科目">{data.subject_name}</Descriptions.Item>
|
<Descriptions.Item label="总分">{data.score} / {data.paper_total_score}</Descriptions.Item>
|
<Descriptions.Item label="试卷">{data.paper_name}</Descriptions.Item>
|
<Descriptions.Item label="考场">{data.room_id} {data.seat_number}</Descriptions.Item>
|
<Descriptions.Item label="考试时间">{data.start_time} ~ {data.end_time}</Descriptions.Item>
|
</Descriptions>
|
</Card>
|
)}
|
{!data && detailLoading && (
|
<div className="attempt-review__center-right-placeholder">
|
<Spin />
|
</div>
|
)}
|
{data && (
|
<>
|
{!isWide && <div className="attempt-review__index-mobile-wrap">{indexPanel}</div>}
|
<div className="attempt-review__index-detail-row">
|
{isWide && (
|
<div
|
className={
|
indexCollapsed
|
? "attempt-review__index-col attempt-review__index-col--collapsed"
|
: "attempt-review__index-col"
|
}
|
>
|
{indexPanel}
|
</div>
|
)}
|
<div ref={detailScrollRef} className="attempt-review__detail">
|
{detailPanel}
|
</div>
|
</div>
|
</>
|
)}
|
</div>
|
);
|
|
return (
|
<div className="attempt-review">
|
<div className="attempt-review__split">
|
{isWide && attemptId && (
|
<div className="attempt-review__roster-col">
|
<AttemptRoster
|
activeAttemptId={attemptId}
|
listQuery={listQuery}
|
onSelectAttempt={selectAttempt}
|
onSearch={searchRoster}
|
/>
|
</div>
|
)}
|
{centerRight}
|
</div>
|
</div>
|
);
|
}
|