yearning
18 小时以前 e3e6db2d9974588f91ac1567a58fa4846655ce1b
1.判断题优化;2.新增考评员是否能查看考生隐私开关
13个文件已修改
2个文件已添加
509 ■■■■■ 已修改文件
backend/app/grading.py 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/import_service.py 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/main.py 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/models.py 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/attempts_router.py 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/scores_router.py 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/settings_router.py 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/schemas.py 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/student_no.py 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/test_api2.py 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/package-lock.json 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/AdminSystemSettingsPage.tsx 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/AttemptReviewPage.tsx 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/ScoreQueryPage.tsx 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/systemSettings.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/grading.py
@@ -10,6 +10,12 @@
    if item_type == 288:
        letters = re.findall(r"[A-Z]", s.replace(",", ""))
        return "".join(sorted(set(letters)))
    if item_type == 320:
        # 判断题兼容:T = 正确(A),F = 错误(B)
        if s == "T":
            return "A"
        if s == "F":
            return "B"
    return s.replace(",", "").strip()
backend/app/import_service.py
@@ -21,6 +21,7 @@
    ImportBatch,
)
from app.sql_parser import find_file_content, rows_from_sql
from app.student_no import generate_student_no
def load_zip_files(zip_bytes: bytes) -> dict[str, bytes]:
@@ -317,6 +318,7 @@
                end_time=r.get("end_time"),
                subject=int(r["subject"]) if r.get("subject") is not None else None,
                import_batch_id=batch.id,
                student_no=generate_student_no(int(r["examinee_id"]), row_exam_id),
            )
            db.add(attempt)
backend/app/main.py
@@ -50,6 +50,21 @@
            )
        except Exception:
            pass
        try:
            conn.exec_driver_sql(
                "ALTER TABLE app_settings ADD COLUMN grader_can_see_privacy INTEGER NOT NULL DEFAULT 0"
            )
        except Exception:
            pass
        try:
            conn.exec_driver_sql(
                "ALTER TABLE examination_exam_examinee ADD COLUMN student_no VARCHAR(16)"
            )
        except Exception:
            pass
        conn.exec_driver_sql(
            "CREATE INDEX IF NOT EXISTS ix_examination_exam_examinee_student_no ON examination_exam_examinee (student_no)"
        )
    from app.database import SessionLocal
    from app.system_settings import get_or_create_settings
backend/app/models.py
@@ -22,6 +22,7 @@
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    system_name: Mapped[str] = mapped_column(String(128), default="考试阅卷系统")
    logo_ext: Mapped[str] = mapped_column(String(16), default="")
    grader_can_see_privacy: Mapped[bool] = mapped_column(Boolean, default=False)
class LoginLock(Base):
@@ -149,6 +150,7 @@
    end_time: Mapped[str | None] = mapped_column(String(32), nullable=True)
    subject: Mapped[int | None] = mapped_column(Integer, nullable=True)
    import_batch_id: Mapped[int] = mapped_column(Integer, ForeignKey("import_batch.id"), index=True)
    student_no: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
class ExaminationExamineeExamAnswer(Base):
backend/app/routers/attempts_router.py
@@ -7,6 +7,8 @@
from app.auth import get_current_user
from app.database import get_db
from app.grader_scope import assert_subject_allowed
from app.student_no import ensure_student_no, find_attempt_by_student_no
from app.system_settings import get_or_create_settings
from app.models import (
    ExamPaperMap,
    ExaminationExamExaminee,
@@ -20,6 +22,21 @@
from app.schemas import AttemptDetail, PartGroup, QuestionDetail
router = APIRouter(prefix="/api", tags=["attempts"])
@router.get("/attempts/lookup/{student_no}")
def lookup_attempt(
    student_no: str,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """根据编码反查答卷,返回 attempt_id 供前端跳转。"""
    attempt = find_attempt_by_student_no(db, student_no)
    if not attempt:
        raise HTTPException(404, detail="编码不存在")
    batch = db.get(ImportBatch, attempt.import_batch_id)
    assert_subject_allowed(db, user, batch.subject_name if batch else None)
    return {"attempt_id": str(attempt.id)}
@router.get("/attempts/{attempt_id}", response_model=AttemptDetail)
@@ -38,6 +55,10 @@
    examinee = db.get(ExaminationExaminee, attempt.examinee_id)
    batch = db.get(ImportBatch, attempt.import_batch_id)
    assert_subject_allowed(db, user, batch.subject_name if batch else None)
    settings = get_or_create_settings(db)
    hide_privacy = user.role == "grader" and not settings.grader_can_see_privacy
    pmap = db.scalar(
        select(ExamPaperMap).where(ExamPaperMap.exam_id == attempt.exam_id)
    )
@@ -84,10 +105,13 @@
            )
        )
    student_no = ensure_student_no(db, attempt)
    return AttemptDetail(
        attempt_id=str(attempt.id),
        examinee_name=examinee.nickname if examinee else None,
        id_card=examinee.id_card if examinee else None,
        student_no=student_no,
        examinee_name=None if hide_privacy else (examinee.nickname if examinee else None),
        id_card=None if hide_privacy else (examinee.id_card if examinee else None),
        score=attempt.score,
        paper_total_score=paper.total_score if paper else None,
        paper_name=paper.paper_name if paper else None,
backend/app/routers/scores_router.py
@@ -5,6 +5,7 @@
from app.auth import get_current_user
from app.database import get_db
from app.grader_scope import allowed_subjects_for_user, apply_subject_scope
from app.student_no import ensure_student_no
from app.models import (
    ExamPaperMap,
    ExaminationExamExaminee,
@@ -14,6 +15,7 @@
    User,
)
from app.schemas import ScoreRow, ScoresResponse
from app.system_settings import get_or_create_settings
router = APIRouter(prefix="/api", tags=["scores"])
@@ -43,12 +45,17 @@
    allowed = allowed_subjects_for_user(db, user)
    if subject and allowed is not None and subject not in allowed:
        raise HTTPException(403, detail="无权查看该科目")
    settings = get_or_create_settings(db)
    hide_privacy = user.role == "grader" and not settings.grader_can_see_privacy
    q = (
        select(
            ExaminationExamExaminee,
            ExaminationExaminee,
            ImportBatch.subject_name,
            ExaminationPaperSource.paper_name,
            ExaminationPaperSource.id,
        )
        .join(ExaminationExaminee, ExaminationExaminee.id == ExaminationExamExaminee.examinee_id)
        .join(ImportBatch, ImportBatch.id == ExaminationExamExaminee.import_batch_id)
@@ -58,10 +65,11 @@
            ExaminationPaperSource.id == ExamPaperMap.paper_source_id,
        )
    )
    if name:
        q = q.where(ExaminationExaminee.nickname.contains(name))
    if id_card:
        q = q.where(ExaminationExaminee.id_card.contains(id_card))
    if not hide_privacy:
        if name:
            q = q.where(ExaminationExaminee.nickname.contains(name))
        if id_card:
            q = q.where(ExaminationExaminee.id_card.contains(id_card))
    if subject:
        q = q.where(ImportBatch.subject_name == subject)
    q = apply_subject_scope(q, ImportBatch.subject_name, allowed)
@@ -77,8 +85,9 @@
    items = [
        ScoreRow(
            examinee_id=str(ex.examinee_id),
            name=ee.nickname,
            id_card=ee.id_card,
            student_no=ensure_student_no(db, ex),
            name=None if hide_privacy else ee.nickname,
            id_card=None if hide_privacy else ee.id_card,
            subject_name=sn,
            score=ex.score,
            attempt_id=str(ex.id),
@@ -87,6 +96,6 @@
            start_time=ex.start_time,
            end_time=ex.end_time,
        )
        for ex, ee, sn, pn in rows
        for ex, ee, sn, pn, ps_id in rows
    ]
    return ScoresResponse(total=total, page=page, page_size=page_size, items=items)
backend/app/routers/settings_router.py
@@ -25,7 +25,11 @@
def _settings_out(db: Session) -> SystemSettingsOut:
    row = get_or_create_settings(db)
    return SystemSettingsOut(system_name=row.system_name, logo_url=public_logo_url(row.logo_ext or None))
    return SystemSettingsOut(
        system_name=row.system_name,
        logo_url=public_logo_url(row.logo_ext or None),
        grader_can_see_privacy=row.grader_can_see_privacy,
    )
@router.get("/settings", response_model=SystemSettingsOut)
@@ -58,6 +62,7 @@
):
    row = get_or_create_settings(db)
    row.system_name = body.system_name.strip()
    row.grader_can_see_privacy = body.grader_can_see_privacy
    db.commit()
    db.refresh(row)
    return _settings_out(db)
backend/app/schemas.py
@@ -4,10 +4,12 @@
class SystemSettingsOut(BaseModel):
    system_name: str
    logo_url: str | None = None
    grader_can_see_privacy: bool = False
class SystemSettingsUpdate(BaseModel):
    system_name: str = Field(min_length=1, max_length=128)
    grader_can_see_privacy: bool = False
class LoginRequest(BaseModel):
@@ -37,6 +39,7 @@
class ScoreRow(BaseModel):
    examinee_id: str
    student_no: str = ""
    name: str | None
    id_card: str | None
    subject_name: str
@@ -76,6 +79,7 @@
class AttemptDetail(BaseModel):
    attempt_id: str
    student_no: str = ""
    examinee_name: str | None
    id_card: str | None
    score: float
backend/app/student_no.py
New file
@@ -0,0 +1,59 @@
import hashlib
import string
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import ExaminationExamExaminee
_ALPHABET = string.digits + string.ascii_uppercase  # 0-9A-Z, 36 chars
def _to_base36(num: int, length: int) -> str:
    """将非负整数编码为定长 base36 字符串。"""
    chars: list[str] = []
    for _ in range(length):
        num, rem = divmod(num, 36)
        chars.append(_ALPHABET[rem])
    return "".join(reversed(chars))
def generate_student_no(examinee_id: int, exam_id: int) -> str:
    """根据考生ID + 考试ID 生成 10 位字母数字编码。"""
    raw = f"{examinee_id}:{exam_id}".encode()
    digest = hashlib.sha256(raw).digest()
    num = int.from_bytes(digest[:8], "big")
    return _to_base36(num, 10)
def ensure_student_no(db: Session, attempt: ExaminationExamExaminee) -> str:
    """获取或生成 attempt 的 student_no(幂等,已有则直接返回)。"""
    if attempt.student_no:
        return attempt.student_no
    code = generate_student_no(attempt.examinee_id, attempt.exam_id)
    # 极小概率碰撞时追加区分
    existing = db.scalar(
        select(ExaminationExamExaminee.id).where(
            ExaminationExamExaminee.student_no == code,
            ExaminationExamExaminee.id != attempt.id,
        )
    )
    if existing is not None:
        digest = hashlib.sha256(
            f"{attempt.examinee_id}:{attempt.exam_id}:{attempt.id}".encode()
        ).digest()
        code = _to_base36(int.from_bytes(digest[:8], "big"), 10)
    attempt.student_no = code
    db.flush()
    return code
def find_attempt_by_student_no(
    db: Session, student_no: str
) -> ExaminationExamExaminee | None:
    """通过编码反查答卷记录。"""
    return db.scalar(
        select(ExaminationExamExaminee).where(
            ExaminationExamExaminee.student_no == student_no.strip().upper()
        )
    )
backend/test_api2.py
New file
@@ -0,0 +1,92 @@
import urllib.request
import json
API = "http://127.0.0.1:8010"
def post(path, body, token=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    data = json.dumps(body).encode()
    req = urllib.request.Request(f"{API}{path}", data=data, headers=headers)
    return json.loads(urllib.request.urlopen(req).read())
def get(path, token=None):
    headers = {}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    req = urllib.request.Request(f"{API}{path}", headers=headers)
    return json.loads(urllib.request.urlopen(req).read())
def put(path, body, token=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    data = json.dumps(body).encode()
    req = urllib.request.Request(f"{API}{path}", data=data, headers=headers, method="PUT")
    return json.loads(urllib.request.urlopen(req).read())
# 1. Admin login
r = post("/api/auth/login", {"username": "admin", "password": "admin123"})
admin_token = r["access_token"]
print("=== Admin login OK ===")
# 2. Assign subject to grader
subs = get("/api/subjects", admin_token)
first_sub = subs["items"][0]
put("/api/admin/graders/2", {
    "username": "grader", "display_name": "", "is_active": True, "subjects": [first_sub]
}, admin_token)
print(f"Grader assigned subject: {first_sub}")
# 3. Grader login
r = post("/api/auth/login", {"username": "grader", "password": "grader123"})
grader_token = r["access_token"]
print("=== Grader login OK ===")
# 4. Check settings (privacy OFF)
settings = get("/api/settings")
print(f"Settings: grader_can_see_privacy={settings['grader_can_see_privacy']}")
# 5. Grader scores (privacy OFF, name/id_card should be null, no search)
scores = get("/api/scores?page=1&page_size=5", grader_token)
print(f"\n=== Grader scores (privacy OFF) ===")
print(f"Total: {scores['total']}")
for item in scores["items"]:
    print(f"  student_no={item['student_no']}, name={item['name']}, id_card={item['id_card']}, score={item['score']}")
# 6. Grader search by name (should be ignored)
scores2 = get("/api/scores?page=1&page_size=5&name=test", grader_token)
print(f"\n=== Grader search name=test (should be ignored, same total) ===")
print(f"Total: {scores2['total']} (expected: {scores['total']})")
# 7. Admin turns ON privacy
put("/api/admin/settings", {
    "system_name": settings["system_name"], "grader_can_see_privacy": True
}, admin_token)
print("\n=== Admin turned ON privacy ===")
# 8. Grader scores (privacy ON, should show full)
scores3 = get("/api/scores?page=1&page_size=5", grader_token)
print(f"\n=== Grader scores (privacy ON) ===")
print(f"Total: {scores3['total']}")
for item in scores3["items"]:
    print(f"  student_no={item['student_no']}, name={item['name']}, id_card={item['id_card']}, score={item['score']}")
# 9. Grader search by name (should work now)
scores4 = get("/api/scores?page=1&page_size=5&name=a", grader_token)
print(f"\n=== Grader search name=a (should work now) ===")
print(f"Total: {scores4['total']}")
# 10. Reset to OFF
put("/api/admin/settings", {
    "system_name": settings["system_name"], "grader_can_see_privacy": False
}, admin_token)
print("\n=== Reset privacy to OFF ===")
# 11. Admin scores (always full)
scores5 = get("/api/scores?page=1&page_size=5", admin_token)
print(f"\n=== Admin scores (always full) ===")
print(f"Total: {scores5['total']}")
for item in scores5["items"]:
    print(f"  student_no={item['student_no']}, name={item['name']}, id_card={item['id_card']}, score={item['score']}")
frontend/package-lock.json
@@ -16,6 +16,7 @@
        "react-router-dom": "^6.28.0"
      },
      "devDependencies": {
        "@playwright/test": "^1.50.1",
        "@types/react": "^18.3.12",
        "@types/react-dom": "^18.3.1",
        "@vitejs/plugin-react": "^4.3.4",
@@ -930,6 +931,22 @@
      ],
      "engines": {
        "node": "^22.20 || ^24.12 || >=25"
      }
    },
    "node_modules/@playwright/test": {
      "version": "1.63.0",
      "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.63.0.tgz",
      "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "playwright": "1.63.0"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/@rc-component/async-validator": {
@@ -2255,6 +2272,35 @@
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/playwright": {
      "version": "1.63.0",
      "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.63.0.tgz",
      "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "playwright-core": "1.63.0"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/playwright-core": {
      "version": "1.63.0",
      "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz",
      "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "playwright-core": "cli.js"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/postcss": {
      "version": "8.5.28",
      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
frontend/src/pages/AdminSystemSettingsPage.tsx
@@ -1,17 +1,20 @@
import { useEffect, useState } from "react";
import { Button, Card, Form, Input, Upload, message, Image, Space } from "antd";
import { Button, Card, Form, Input, Upload, message, Image, Space, Switch } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { api } from "../api";
import { logoSrc, useSystemSettings } from "../systemSettings";
export default function AdminSystemSettingsPage() {
  const { settings, refresh, logoCacheKey } = useSystemSettings();
  const [form] = Form.useForm<{ system_name: string }>();
  const [form] = Form.useForm<{ system_name: string; grader_can_see_privacy: boolean }>();
  const [saving, setSaving] = useState(false);
  useEffect(() => {
    form.setFieldsValue({ system_name: settings.system_name });
  }, [settings.system_name, form]);
    form.setFieldsValue({
      system_name: settings.system_name,
      grader_can_see_privacy: settings.grader_can_see_privacy,
    });
  }, [settings.system_name, settings.grader_can_see_privacy, form]);
  const logo = logoSrc(settings.logo_url, logoCacheKey);
@@ -24,7 +27,10 @@
        onFinish={async (v) => {
          setSaving(true);
          try {
            await api.put("/api/admin/settings", { system_name: v.system_name });
            await api.put("/api/admin/settings", {
              system_name: v.system_name,
              grader_can_see_privacy: v.grader_can_see_privacy,
            });
            message.success("已保存");
            await refresh();
          } catch (e: unknown) {
@@ -39,7 +45,15 @@
        <Form.Item name="system_name" label="系统名称" rules={[{ required: true, max: 128 }]}>
          <Input placeholder="显示在顶栏、登录页与浏览器标题" />
        </Form.Item>
        <Button type="primary" htmlType="submit" loading={saving}>保存名称</Button>
        <Form.Item
          name="grader_can_see_privacy"
          label="考评员可见考生隐私"
          valuePropName="checked"
          extra="开启后,考评员可以看到考生完整姓名和证件号;关闭后姓名和证件号将脱敏显示"
        >
          <Switch />
        </Form.Item>
        <Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
      </Form>
      <Card type="inner" title="系统 Logo" style={{ marginTop: 24, maxWidth: 480 }}>
frontend/src/pages/AttemptReviewPage.tsx
@@ -20,6 +20,8 @@
  readScoreListQuery,
  type ScoreListQuery,
} from "../scoreQueryParams";
import { useSystemSettings } from "../systemSettings";
import type { UserMe } from "../App";
import "./AttemptReviewPage.css";
const { useBreakpoint } = Grid;
@@ -30,6 +32,7 @@
type Q = {
  external_item_id: string;
  part_name?: string;
  item_type?: number;
  item_order?: number;
  title_html?: string;
  detail_json?: string;
@@ -44,6 +47,7 @@
type Attempt = {
  attempt_id: string;
  student_no?: string;
  examinee_name?: string;
  id_card?: string;
  score: number;
@@ -61,6 +65,17 @@
function stripHtml(html: string) {
  return html.replace(/<[^>]+>/g, "");
}
/** 判断题答案格式化:T→正确(A),F→错误(B) */
function formatAnswer(raw: string | undefined, itemType?: number): string {
  if (!raw) return "—";
  if (itemType === 320) {
    const s = raw.trim().toUpperCase();
    if (s === "T" || s === "A") return "正确(A)";
    if (s === "F" || s === "B") return "错误(B)";
  }
  return raw;
}
function isAnswered(q: Q) {
@@ -197,6 +212,7 @@
  attempt_id: string;
  name: string;
  id_card: string;
  student_no: string;
  subject_name: string;
  score: number;
};
@@ -206,11 +222,13 @@
function AttemptRoster({
  activeAttemptId,
  listQuery,
  hidePrivacy,
  onSelectAttempt,
  onSearch,
}: {
  activeAttemptId: string;
  listQuery: ScoreListQuery;
  hidePrivacy: boolean;
  onSelectAttempt: (attemptId: string) => void;
  onSearch: (filters: Pick<ScoreListQuery, "name" | "id_card" | "subject">) => void;
}) {
@@ -303,12 +321,16 @@
        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>
        {!hidePrivacy && (
          <>
            <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>
@@ -330,7 +352,7 @@
              if (row.attempt_id !== activeAttemptId) onSelectAttempt(row.attempt_id);
            }}
          >
            <div className="attempt-review__roster-name">{row.name || "—"}</div>
            <div className="attempt-review__roster-name">{hidePrivacy ? row.student_no : (row.name || "—")}</div>
            <div className="attempt-review__roster-meta">
              <span>{row.score} 分</span>
              <span className="attempt-review__roster-subject">{row.subject_name}</span>
@@ -359,6 +381,17 @@
  const detailScrollRef = useRef<HTMLDivElement>(null);
  const screens = useBreakpoint();
  const isWide = !!screens.lg;
  const { settings } = useSystemSettings();
  const [user, setUser] = useState<UserMe | null>(null);
  const hidePrivacy = user?.role === "grader" && !settings.grader_can_see_privacy;
  useEffect(() => {
    api
      .get<UserMe>("/api/auth/me")
      .then((r) => setUser(r.data))
      .catch(() => setUser(null));
  }, []);
  const selectAttempt = useCallback(
    (id: string) => {
@@ -491,8 +524,8 @@
                    <li key={opt.identifier}>{opt.identifier}. {stripHtml(opt.text)}</li>
                  ))}
                </ul>
                <p>考生答案:{q.user_answer || "—"}</p>
                <p>参考答案:{q.reference_answer || "—"}</p>
                <p>考生答案:{formatAnswer(q.user_answer, q.item_type)}</p>
                <p>参考答案:{formatAnswer(q.reference_answer, q.item_type)}</p>
                <p>
                  {q.is_correct ? <Tag color="success">正确</Tag> : <Tag color="error">错误</Tag>}
                  得分:{q.score} / {q.max_score ?? 1}
@@ -515,8 +548,13 @@
      {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.student_no}</Descriptions.Item>
            {!hidePrivacy && (
              <>
                <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>
@@ -562,6 +600,7 @@
            <AttemptRoster
              activeAttemptId={attemptId}
              listQuery={listQuery}
              hidePrivacy={hidePrivacy}
              onSelectAttempt={selectAttempt}
              onSearch={searchRoster}
            />
frontend/src/pages/ScoreQueryPage.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Button, Card, Form, Input, Select, Table } from "antd";
import { Button, Card, Form, Input, message, Select, Table } from "antd";
import { useNavigate, useSearchParams } from "react-router-dom";
import { api } from "../api";
import {
@@ -8,12 +8,15 @@
  writeScoreListQuery,
  type ScoreListQuery,
} from "../scoreQueryParams";
import { useSystemSettings } from "../systemSettings";
import type { UserMe } from "../App";
import "./ScoreQueryPage.css";
type ScoreRow = {
  examinee_id: string;
  name: string;
  id_card: string;
  student_no: string;
  name: string | null;
  id_card: string | null;
  subject_name: string;
  score: number;
  attempt_id: string;
@@ -38,6 +41,18 @@
    subject: query.subject,
  });
  const [tableScrollY, setTableScrollY] = useState(320);
  const [user, setUser] = useState<UserMe | null>(null);
  const { settings } = useSystemSettings();
  const hidePrivacy =
    user?.role === "grader" && !settings.grader_can_see_privacy;
  useEffect(() => {
    api
      .get<UserMe>("/api/auth/me")
      .then((r) => setUser(r.data))
      .catch(() => setUser(null));
  }, []);
  useEffect(() => {
    api.get<{ items: string[] }>("/api/subjects").then((r) => setSubjects(r.data.items));
@@ -56,16 +71,20 @@
  }, [searchParams, form]);
  const load = async () => {
    const { data: res } = await api.get("/api/scores", {
      params: { page, page_size: pageSize, ...filters },
    });
    const params: Record<string, string | number> = { page, page_size: pageSize };
    if (!hidePrivacy) {
      if (filters.name) params.name = filters.name;
      if (filters.id_card) params.id_card = filters.id_card;
    }
    if (filters.subject) params.subject = filters.subject;
    const { data: res } = await api.get("/api/scores", { params });
    setData(res.items);
    setTotal(res.total);
  };
  useEffect(() => {
    load();
  }, [page, pageSize, filters]);
  }, [page, pageSize, filters, hidePrivacy]);
  useEffect(() => {
    const el = tableWrapRef.current;
@@ -90,6 +109,52 @@
    setSearchParams(writeScoreListQuery(q), { replace: true });
  };
  const [studentNo, setStudentNo] = useState("");
  const handleLookup = async () => {
    if (!studentNo.trim()) return;
    try {
      const { data } = await api.get<{ attempt_id: string }>(
        `/api/attempts/lookup/${encodeURIComponent(studentNo.trim().toUpperCase())}`
      );
      nav(attemptDetailPath(data.attempt_id, listQuery()));
    } catch {
      message.error("考生编号不存在");
    }
  };
  const columns = [
    {
      title: "序号",
      width: 64,
      fixed: "left" as const,
      render: (_: unknown, __: unknown, index: number) => (page - 1) * pageSize + index + 1,
    },
    { title: "考生编号", dataIndex: "student_no", width: 180, fixed: "left" as const },
    ...(hidePrivacy
      ? []
      : [
          { title: "姓名", dataIndex: "name", width: 100 },
          { title: "证件号", dataIndex: "id_card", width: 180 },
        ]),
    { title: "科目", dataIndex: "subject_name", width: 200, ellipsis: true },
    { title: "开始考试时间", dataIndex: "start_time", width: 170, render: (v: string | null) => v || "—" },
    { title: "结束考试时间", dataIndex: "end_time", width: 170, render: (v: string | null) => v || "—" },
    { title: "成绩", dataIndex: "score", width: 80 },
    {
      title: "操作",
      width: 80,
      fixed: "right" as const,
      render: (_: unknown, row: ScoreRow) => (
        <Button
          type="link"
          onClick={() => nav(attemptDetailPath(row.attempt_id, listQuery()))}
        >
          查看
        </Button>
      ),
    },
  ];
  return (
    <div className="score-query-page">
      <Card bordered className="score-query-page__card">
@@ -101,12 +166,16 @@
            pushQuery({ ...v, page: 1, page_size: pageSize });
          }}
        >
          <Form.Item name="name" label="姓名">
            <Input allowClear style={{ width: 120 }} />
          </Form.Item>
          <Form.Item name="id_card" label="证件号">
            <Input allowClear style={{ width: 180 }} />
          </Form.Item>
          {!hidePrivacy && (
            <>
              <Form.Item name="name" label="姓名">
                <Input allowClear style={{ width: 120 }} />
              </Form.Item>
              <Form.Item name="id_card" label="证件号">
                <Input allowClear style={{ width: 180 }} />
              </Form.Item>
            </>
          )}
          <Form.Item name="subject" label="科目">
            <Select
              allowClear
@@ -118,10 +187,21 @@
            <Button type="primary" htmlType="submit">查询</Button>
          </Form.Item>
        </Form>
        <div style={{ marginBottom: 12, display: "flex", gap: 8, alignItems: "center" }}>
          <Input
            placeholder="输入考生编号直接查看答卷"
            value={studentNo}
            onChange={(e) => setStudentNo(e.target.value.toUpperCase())}
            onPressEnter={handleLookup}
            style={{ width: 200, fontFamily: "monospace" }}
            maxLength={10}
          />
          <Button onClick={handleLookup} disabled={!studentNo.trim()}>查看答卷</Button>
        </div>
        <div ref={tableWrapRef} className="score-query-page__table-wrap">
          <Table
            rowKey="attempt_id"
            scroll={{ x: 1100, y: tableScrollY }}
            scroll={{ x: hidePrivacy ? 800 : 1100, y: tableScrollY }}
            dataSource={data}
            pagination={{
              current: page,
@@ -134,33 +214,7 @@
                pushQuery({ ...filters, page: p, page_size: ps });
              },
            }}
            columns={[
              {
                title: "序号",
                width: 64,
                fixed: "left",
                render: (_, __, index) => (page - 1) * pageSize + index + 1,
              },
              { title: "姓名", dataIndex: "name", width: 100 },
              { title: "证件号", dataIndex: "id_card", width: 180 },
              { title: "科目", dataIndex: "subject_name", width: 200, ellipsis: true },
              { title: "开始考试时间", dataIndex: "start_time", width: 170, render: (v) => v || "—" },
              { title: "结束考试时间", dataIndex: "end_time", width: 170, render: (v) => v || "—" },
              { title: "成绩", dataIndex: "score", width: 80 },
              {
                title: "操作",
                width: 80,
                fixed: "right",
                render: (_, row) => (
                  <Button
                    type="link"
                    onClick={() => nav(attemptDetailPath(row.attempt_id, listQuery()))}
                  >
                    查看
                  </Button>
                ),
              },
            ]}
            columns={columns}
          />
        </div>
      </Card>
frontend/src/systemSettings.tsx
@@ -4,11 +4,13 @@
export type SystemSettings = {
  system_name: string;
  logo_url: string | null;
  grader_can_see_privacy: boolean;
};
const DEFAULT: SystemSettings = {
  system_name: "考试阅卷系统",
  logo_url: null,
  grader_can_see_privacy: false,
};
const SystemSettingsContext = createContext<{