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