1.判断题优化;2.新增考评员是否能查看考生隐私开关
| | |
| | | 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() |
| | | |
| | | |
| | |
| | | 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]: |
| | |
| | | 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) |
| | | |
| | |
| | | ) |
| | | 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 |
| | |
| | | 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): |
| | |
| | | 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): |
| | |
| | | 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, |
| | |
| | | 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) |
| | |
| | | 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) |
| | | ) |
| | |
| | | ) |
| | | ) |
| | | |
| | | 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, |
| | |
| | | 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, |
| | |
| | | User, |
| | | ) |
| | | from app.schemas import ScoreRow, ScoresResponse |
| | | from app.system_settings import get_or_create_settings |
| | | |
| | | router = APIRouter(prefix="/api", tags=["scores"]) |
| | | |
| | |
| | | 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) |
| | |
| | | ExaminationPaperSource.id == ExamPaperMap.paper_source_id, |
| | | ) |
| | | ) |
| | | if not hide_privacy: |
| | | if name: |
| | | q = q.where(ExaminationExaminee.nickname.contains(name)) |
| | | if id_card: |
| | |
| | | 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), |
| | |
| | | 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) |
| | |
| | | |
| | | 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) |
| | |
| | | ): |
| | | 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) |
| | |
| | | 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): |
| | |
| | | |
| | | class ScoreRow(BaseModel): |
| | | examinee_id: str |
| | | student_no: str = "" |
| | | name: str | None |
| | | id_card: str | None |
| | | subject_name: str |
| | |
| | | |
| | | class AttemptDetail(BaseModel): |
| | | attempt_id: str |
| | | student_no: str = "" |
| | | examinee_name: str | None |
| | | id_card: str | None |
| | | score: float |
| New file |
| | |
| | | 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() |
| | | ) |
| | | ) |
| New file |
| | |
| | | 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']}") |
| | |
| | | "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", |
| | |
| | | ], |
| | | "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": { |
| | |
| | | "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", |
| | |
| | | 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); |
| | | |
| | |
| | | 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) { |
| | |
| | | <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 }}> |
| | |
| | | readScoreListQuery, |
| | | type ScoreListQuery, |
| | | } from "../scoreQueryParams"; |
| | | import { useSystemSettings } from "../systemSettings"; |
| | | import type { UserMe } from "../App"; |
| | | import "./AttemptReviewPage.css"; |
| | | |
| | | const { useBreakpoint } = Grid; |
| | |
| | | type Q = { |
| | | external_item_id: string; |
| | | part_name?: string; |
| | | item_type?: number; |
| | | item_order?: number; |
| | | title_html?: string; |
| | | detail_json?: string; |
| | |
| | | |
| | | type Attempt = { |
| | | attempt_id: string; |
| | | student_no?: string; |
| | | examinee_name?: string; |
| | | id_card?: string; |
| | | score: number; |
| | |
| | | |
| | | 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) { |
| | |
| | | attempt_id: string; |
| | | name: string; |
| | | id_card: string; |
| | | student_no: string; |
| | | subject_name: string; |
| | | score: number; |
| | | }; |
| | |
| | | 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; |
| | | }) { |
| | |
| | | wrapperCol={{ flex: "1 1 0" }} |
| | | onFinish={(v) => onSearch(v)} |
| | | > |
| | | {!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> |
| | |
| | | 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> |
| | |
| | | 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) => { |
| | |
| | | <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} |
| | |
| | | {data && ( |
| | | <Card className="attempt-review__header" size="small"> |
| | | <Descriptions column={{ xs: 1, sm: 2 }} size="small"> |
| | | <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> |
| | |
| | | <AttemptRoster |
| | | activeAttemptId={attemptId} |
| | | listQuery={listQuery} |
| | | hidePrivacy={hidePrivacy} |
| | | onSelectAttempt={selectAttempt} |
| | | onSearch={searchRoster} |
| | | /> |
| | |
| | | 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 { |
| | |
| | | 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; |
| | |
| | | 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)); |
| | |
| | | }, [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; |
| | |
| | | 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"> |
| | |
| | | pushQuery({ ...v, page: 1, page_size: pageSize }); |
| | | }} |
| | | > |
| | | {!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 |
| | |
| | | <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, |
| | |
| | | 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> |
| | |
| | | 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<{ |