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