From e3e6db2d9974588f91ac1567a58fa4846655ce1b Mon Sep 17 00:00:00 2001
From: yearning <10538594+wangweifeng1999@user.noreply.gitee.com>
Date: 星期三, 30 九月 2026 12:54:55 +0800
Subject: [PATCH] 1.判断题优化;2.新增考评员是否能查看考生隐私开关

---
 backend/app/student_no.py                      |   59 ++++++
 backend/app/routers/settings_router.py         |    7 
 frontend/src/pages/AdminSystemSettingsPage.tsx |   26 ++
 backend/app/grading.py                         |    6 
 frontend/src/pages/AttemptReviewPage.tsx       |   61 +++++-
 backend/app/schemas.py                         |    4 
 frontend/package-lock.json                     |   46 +++++
 backend/app/routers/scores_router.py           |   23 +
 frontend/src/systemSettings.tsx                |    2 
 backend/test_api2.py                           |   92 ++++++++++
 backend/app/routers/attempts_router.py         |   28 ++
 backend/app/import_service.py                  |    2 
 backend/app/models.py                          |    2 
 backend/app/main.py                            |   15 +
 frontend/src/pages/ScoreQueryPage.tsx          |  136 ++++++++++----
 15 files changed, 441 insertions(+), 68 deletions(-)

diff --git a/backend/app/grading.py b/backend/app/grading.py
index 7241828..a4de298 100644
--- a/backend/app/grading.py
+++ b/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 = 姝g‘(A)锛孎 = 閿欒(B)
+        if s == "T":
+            return "A"
+        if s == "F":
+            return "B"
     return s.replace(",", "").strip()
 
 
diff --git a/backend/app/import_service.py b/backend/app/import_service.py
index 0e8f72b..f52fbb7 100644
--- a/backend/app/import_service.py
+++ b/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)
 
diff --git a/backend/app/main.py b/backend/app/main.py
index dfd53c4..7ed65de 100644
--- a/backend/app/main.py
+++ b/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
diff --git a/backend/app/models.py b/backend/app/models.py
index a016419..5aa73f5 100644
--- a/backend/app/models.py
+++ b/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):
diff --git a/backend/app/routers/attempts_router.py b/backend/app/routers/attempts_router.py
index c5aad61..a142374 100644
--- a/backend/app/routers/attempts_router.py
+++ b/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,
diff --git a/backend/app/routers/scores_router.py b/backend/app/routers/scores_router.py
index e3cff3a..969dea2 100644
--- a/backend/app/routers/scores_router.py
+++ b/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)
diff --git a/backend/app/routers/settings_router.py b/backend/app/routers/settings_router.py
index 371ce26..f467769 100644
--- a/backend/app/routers/settings_router.py
+++ b/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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 9b116ad..8ec01f6 100644
--- a/backend/app/schemas.py
+++ b/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
diff --git a/backend/app/student_no.py b/backend/app/student_no.py
new file mode 100644
index 0000000..6fe57a4
--- /dev/null
+++ b/backend/app/student_no.py
@@ -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()
+        )
+    )
diff --git a/backend/test_api2.py b/backend/test_api2.py
new file mode 100644
index 0000000..d63deb8
--- /dev/null
+++ b/backend/test_api2.py
@@ -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']}")
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1a5be56..0c3ac07 100644
--- a/frontend/package-lock.json
+++ b/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",
diff --git a/frontend/src/pages/AdminSystemSettingsPage.tsx b/frontend/src/pages/AdminSystemSettingsPage.tsx
index 33f318c..1bb32a3 100644
--- a/frontend/src/pages/AdminSystemSettingsPage.tsx
+++ b/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 }}>
diff --git a/frontend/src/pages/AttemptReviewPage.tsx b/frontend/src/pages/AttemptReviewPage.tsx
index 7e503ff..23e6503 100644
--- a/frontend/src/pages/AttemptReviewPage.tsx
+++ b/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, "");
+}
+
+/** 鍒ゆ柇棰樼瓟妗堟牸寮忓寲锛歍鈫掓纭紙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 "姝g‘锛圓锛�";
+    if (s === "F" || s === "B") return "閿欒锛圔锛�";
+  }
+  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">姝g‘</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}
             />
diff --git a/frontend/src/pages/ScoreQueryPage.tsx b/frontend/src/pages/ScoreQueryPage.tsx
index e3f3141..99e9b47 100644
--- a/frontend/src/pages/ScoreQueryPage.tsx
+++ b/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>
diff --git a/frontend/src/systemSettings.tsx b/frontend/src/systemSettings.tsx
index b4b3233..ab9e40d 100644
--- a/frontend/src/systemSettings.tsx
+++ b/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<{

--
Gitblit v1.8.0