From 3f96c9c6502243b06efd4bb34a189e72e3a5557c Mon Sep 17 00:00:00 2001
From: yearning <10538594+wangweifeng1999@user.noreply.gitee.com>
Date: 星期一, 28 九月 2026 19:24:55 +0800
Subject: [PATCH] 复制项目
---
frontend/src/pages/AttemptReviewPage.tsx | 574 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 574 insertions(+), 0 deletions(-)
diff --git a/frontend/src/pages/AttemptReviewPage.tsx b/frontend/src/pages/AttemptReviewPage.tsx
new file mode 100644
index 0000000..7e503ff
--- /dev/null
+++ b/frontend/src/pages/AttemptReviewPage.tsx
@@ -0,0 +1,574 @@
+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">姝g‘</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>
+ );
+}
--
Gitblit v1.8.0